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
+324
View File
@@ -0,0 +1,324 @@
/**
* The search palette and the trail, without a browser (CG-45).
*
* Two things here can be silently wrong rather than merely ugly. The palette's
* flat item list must be exactly the concatenation of the sections it draws, or
* ↑/↓/Enter follows a different row than the one under the highlight. And the
* trail's wire format must round-trip, because it is the whole reason a walk
* survives a reload or travels in a shared link.
*
* The geometry-free half of the same split as `ui-symbol-model.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import {
buildEntryPalette,
buildSearchPalette,
groupByKind,
interleaveResults,
kindGroupTitle,
locationOf,
moveSelection,
parseFlowQuery,
} from '../ui/src/lib/search-model';
import { decodeTrail, encodeTrail, hopLabel, type TrailHop } from '../ui/src/lib/trail-codec';
import type { WireEntryPoints, WireSearch, WireSearchResult } from '../ui/src/lib/api';
/* ------------------------------------------------------------- fixtures -- */
function result(over: Partial<WireSearchResult> = {}): WireSearchResult {
return {
id: over.id ?? `method:${over.name ?? 'load'}`,
kind: 'method',
name: 'load',
qualifiedName: 'Service::load',
file: 'src/service.ts',
line: 42,
endLine: 60,
language: 'typescript',
test: false,
matchKind: 'exact',
...over,
} as WireSearchResult;
}
function answer(items: WireSearchResult[]): WireSearch {
return {
query: 'q',
text: 'q',
filters: { kinds: [], languages: [], paths: [], names: [] },
results: { total: items.length, shown: items.length, truncated: false, items },
groups: [],
};
}
/* ----------------------------------------------------------- flow query -- */
describe('the flow grammar', () => {
it('recognises the three shapes the placeholder advertises', () => {
expect(parseFlowQuery('how does execute reach getFile')).toEqual({
from: 'execute',
to: 'getFile',
});
expect(parseFlowQuery('execute -> getFile')).toEqual({ from: 'execute', to: 'getFile' });
expect(parseFlowQuery('execute → getFile')).toEqual({ from: 'execute', to: 'getFile' });
expect(parseFlowQuery(' sync reaches indexFile? ')).toEqual({
from: 'sync',
to: 'indexFile',
});
});
it('asks about the last segment of a qualified name', () => {
// `Class.method` names the method; the class is how you say WHICH one, and
// the search ranks that out on its own.
expect(parseFlowQuery('how does CodeGraph.sync reach Cache.read')).toEqual({
from: 'sync',
to: 'read',
});
});
it('leaves an ordinary search alone', () => {
expect(parseFlowQuery('getImpactRadius')).toBeNull();
expect(parseFlowQuery('kind:class Cache')).toBeNull();
expect(parseFlowQuery('how does this work')).toBeNull();
// A symbol reaching itself is not a path worth asking about.
expect(parseFlowQuery('sync -> sync')).toBeNull();
});
});
/* -------------------------------------------------------------- palette -- */
describe('the palette', () => {
it('flattens exactly what it draws, in draw order', () => {
const palette = buildSearchPalette(
[
answer([
result({ id: 'm1', name: 'load', kind: 'method' }),
result({ id: 'f1', name: 'loader', kind: 'function' }),
result({ id: 'm2', name: 'reload', kind: 'method' }),
]),
],
null
);
// Groups appear where their best result did, so flattening reproduces the
// ranking the keyboard walks.
expect(palette.sections.map((s) => s.title)).toEqual(['Methods', 'Function']);
expect(palette.items.map((i) => i.id)).toEqual(['m1', 'm2', 'f1']);
expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
expect(palette.empty).toBeNull();
});
it('says nothing matched instead of drawing an empty box', () => {
const palette = buildSearchPalette([answer([])], null);
expect(palette.items).toEqual([]);
expect(palette.empty).toContain('No symbol or file');
});
it('interleaves a flow question so neither endpoint outranks the other', () => {
const a = [result({ id: 'a1' }), result({ id: 'a2' })];
const b = [result({ id: 'b1' }), result({ id: 'b2' })];
expect(interleaveResults(a, b).map((r) => r.id)).toEqual(['a1', 'b1', 'a2', 'b2']);
// A symbol that matched both halves keeps its earliest position.
expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']);
});
it('explains that a flow question is answered by both endpoints for now', () => {
const palette = buildSearchPalette(
[answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])],
{ from: 'sync', to: 'read' }
);
expect(palette.items.map((i) => i.id)).toEqual(['a', 'b']);
expect(palette.hint).toContain('sync');
expect(palette.hint).toContain('read');
});
it('names a kind bucket in sentence case, singular when there is one', () => {
expect(kindGroupTitle('method', 3)).toBe('Methods');
expect(kindGroupTitle('method', 1)).toBe('Method');
expect(kindGroupTitle('type_alias', 2)).toBe('Type aliases');
expect(kindGroupTitle('class', 2)).toBe('Classes');
});
it('locates a symbol by file and line, and a file by its directory', () => {
expect(locationOf(result({ file: 'src/mcp/tools.ts', line: 412 }))).toBe('tools.ts:412');
// The name column is already the basename; repeating the path says nothing.
expect(
locationOf(result({ kind: 'file', file: 'src/bin/codegraph.ts', name: 'codegraph.ts' }))
).toBe('src/bin');
expect(locationOf(result({ kind: 'file', file: 'README.md', name: 'README.md' }))).toBe(
'project root'
);
});
it('groups by kind without losing a row', () => {
const results = [
result({ id: '1', kind: 'class' }),
result({ id: '2', kind: 'method' }),
result({ id: '3', kind: 'class' }),
];
const sections = groupByKind(results);
expect(sections.map((s) => s.title)).toEqual(['Classes', 'Method']);
expect(sections.flatMap((s) => s.items).map((i) => i.id)).toEqual(['1', '3', '2']);
});
it('wraps the selection at both ends', () => {
expect(moveSelection(0, -1, 3)).toBe(2);
expect(moveSelection(2, 1, 3)).toBe(0);
expect(moveSelection(0, 1, 3)).toBe(1);
// An empty list has one legal selection, and it is not -1.
expect(moveSelection(0, 1, 0)).toBe(0);
});
});
/* --------------------------------------------------------- entry points -- */
function entryPoints(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
return {
routes: { routed: false, routeCount: 0, items: [] },
files: {
total: 2,
shown: 2,
truncated: false,
items: [
{
...result({ id: 'file:src/bin/codegraph.ts', kind: 'file', name: 'codegraph.ts' }),
file: 'src/bin/codegraph.ts',
calls: 9,
reaches: 37,
dependents: 3,
},
] as any,
},
hubs: {
total: 1,
shown: 1,
truncated: false,
items: [{ ...result({ id: 'method:get', name: 'get' }), dependents: 264 }] as any,
},
...over,
} as WireEntryPoints;
}
describe('the entry points', () => {
it('says what each row is derived from, not that it IS the entry point', () => {
const palette = buildEntryPalette(entryPoints());
expect(palette.sections.map((s) => s.title)).toEqual([
'Files that run something',
'Most depended on',
]);
expect(palette.sections[0]?.items[0]?.meta).toBe(
'9 calls at module level · reaches 37 files'
);
expect(palette.sections[1]?.items[0]?.meta).toBe('264 dependents');
expect(palette.items).toHaveLength(2);
});
it('puts routes first, and carries the id that makes a row clickable', () => {
const palette = buildEntryPalette(
entryPoints({
routes: {
routed: true,
routeCount: 4,
items: [
{
url: 'GET /users',
handler: 'listUsers',
file: 'src/routes.ts',
line: 11,
handlerId: 'function:listUsers',
},
],
},
})
);
expect(palette.sections[0]?.title).toBe('Routes');
const row = palette.items[0];
expect(row?.type).toBe('route');
if (row?.type === 'route') {
expect(row.url).toBe('GET /users');
expect(row.nodeId).toBe('function:listUsers');
expect(row.location).toBe('routes.ts:11');
}
});
it('shortens each section for the panel under the box', () => {
const many = entryPoints();
(many.hubs.items as any) = Array.from({ length: 10 }, (_, i) => ({
...result({ id: `m${i}`, name: `hub${i}` }),
dependents: 100 - i,
}));
expect(buildEntryPalette(many, { perSection: 3 }).items).toHaveLength(4);
expect(buildEntryPalette(many).items).toHaveLength(11);
});
it('draws nothing at all before the answer arrives', () => {
const palette = buildEntryPalette(null);
expect(palette.sections).toEqual([]);
// Not an "empty" message: nothing is known yet, and saying "this index has
// nothing" while the request is in flight would be a claim, not a state.
expect(palette.empty).toBeNull();
});
});
/* ----------------------------------------------------------------- trail -- */
function hop(id: string, dir: TrailHop['dir']): TrailHop {
return { id, name: null, kind: null, dir };
}
describe('the trail in the URL', () => {
it('round-trips six hops with their directions intact', () => {
const walked: TrailHop[] = [
hop('method:a', 'start'),
hop('method:b', 'down'),
hop('method:c', 'down'),
hop('method:d', 'up'),
hop('method:e', 'down'),
hop('file:src/bin/codegraph.ts', 'up'),
];
const encoded = encodeTrail(walked);
const decoded = decodeTrail(encoded);
expect(decoded).toHaveLength(6);
expect(decoded.map((h) => h.id)).toEqual(walked.map((h) => h.id));
expect(decoded.map((h) => h.dir)).toEqual(['start', 'down', 'down', 'up', 'down', 'up']);
// Re-encoding is byte-identical, which is what makes a shared link stable.
expect(encodeTrail(decoded)).toBe(encoded);
});
it('keeps an id that begins with a direction letter', () => {
// `union:…` and `default:…` start with 'u' and 'd'; an optional direction
// prefix would swallow the first character of the id.
const hops = [hop('union:Shape', 'start'), hop('declaration:x', 'down')];
expect(decodeTrail(encodeTrail(hops)).map((h) => h.id)).toEqual([
'union:Shape',
'declaration:x',
]);
});
it('survives an id carrying the separator, and a hand-mangled param', () => {
const hops = [hop('file:src/a,b.ts', 'start')];
expect(decodeTrail(encodeTrail(hops))[0]?.id).toBe('file:src/a,b.ts');
expect(decodeTrail(null)).toEqual([]);
expect(decodeTrail('')).toEqual([]);
// A token with no direction letter is dropped; a lone '%' would throw in
// decodeURIComponent, so the raw text is kept instead — a hop that names
// nothing is better than a trail that silently loses a position.
expect(decodeTrail('x,,smethod%3Aa,d%')).toEqual([
{ id: 'method:a', name: null, kind: null, dir: 'start' },
{ id: '%', name: null, kind: null, dir: 'down' },
]);
});
it('labels an unresolved hop with something readable, never a raw hash', () => {
expect(hopLabel({ ...hop('method:x', 'down'), name: 'load' })).toBe('load');
expect(hopLabel(hop('file:src/bin/codegraph.ts', 'start'))).toBe('codegraph.ts');
expect(hopLabel(hop('method:ada8ef1603fc03e3566eec72dc91138f', 'down'))).toBe('ada8ef16…');
});
});
+136
View File
@@ -175,6 +175,22 @@ export function handleRequest(service: Service, key: string): string {
` `
); );
// Module-level statements: the engine records them as edges out of the FILE
// node, which is the only reason `/api/entrypoints` can see an executable
// root at all. Nothing else in the fixture runs anything on the way down.
fs.writeFileSync(
path.join(srcDir, 'main.ts'),
`import { Service } from './service';
import { handleRequest } from './handler';
const service = new Service({ ttlMs: 5, label: 'main' });
const first = handleRequest(service, 'boot');
const second = service.load('warm');
export const started = [first, second];
`
);
// 500 callers into one function: the N+1 and capping behaviour only shows up // 500 callers into one function: the N+1 and capping behaviour only shows up
// at this scale, and the fixture keeps CI honest without needing the engine's // at this scale, and the fixture keeps CI honest without needing the engine's
// own index to be present. // own index to be present.
@@ -209,6 +225,10 @@ export function testLoadsThroughCache(): void {
const service = new Service({ ttlMs: 1, label: 'x' }); const service = new Service({ ttlMs: 1, label: 'x' });
service.load('k'); service.load('k');
} }
// Module level, on purpose: a test file that RUNS something must still be
// excluded from the entry points.
testLoadsThroughCache();
` `
); );
@@ -910,6 +930,20 @@ export default app;
expect(handler.node.name).toBe('listUsers'); expect(handler.node.name).toBe('listUsers');
}); });
it('offers its routes as entry points, ahead of anything derived', async () => {
const res = await requestOn(routedServer.port, '/api/entrypoints');
const body = JSON.parse(res.body);
expect(body.routes.routed).toBe(true);
expect(body.routes.routeCount).toBe(4);
const urls = body.routes.items.map((e: any) => e.url);
expect(urls).toEqual(
expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
);
// A route row has to be navigable, or it is a label.
expect(body.routes.items.every((e: any) => e.handlerId)).toBe(true);
});
it('honours the limit and says when it cut the list', async () => { it('honours the limit and says when it cut the list', async () => {
const res = await requestOn(routedServer.port, '/api/routes?limit=3'); const res = await requestOn(routedServer.port, '/api/routes?limit=3');
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
@@ -998,6 +1032,108 @@ describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))(
} }
); );
describe('GET /api/entrypoints', () => {
it('finds the file that runs something, and reports what it reaches', async () => {
const body = await getJson('/api/entrypoints');
const files = body.files.items.map((f: any) => f.file);
expect(files).toContain('src/main.ts');
const main = body.files.items.find((f: any) => f.file === 'src/main.ts');
expect(main.kind).toBe('file');
expect(main.id).toMatch(/^file:/);
// `new Service(...)`, `handleRequest(...)` and `service.load(...)` all sit
// at module level.
expect(main.calls).toBeGreaterThanOrEqual(2);
// It imports from service.ts and handler.ts, so it wires files together.
expect(main.reaches).toBeGreaterThanOrEqual(2);
expect(typeof main.dependents).toBe('number');
});
it('leaves test files out — "where do I start" never means a test', async () => {
const body = await getJson('/api/entrypoints');
for (const file of body.files.items) expect(file.test).toBe(false);
// The fixture's test file calls its own helper at module level, so it IS a
// candidate by the raw graph signal and is excluded deliberately.
expect(body.files.items.map((f: any) => f.file)).not.toContain(
'__tests__/service.test.ts'
);
for (const hub of body.hubs.items) expect(hub.test).toBe(false);
});
it('ranks the most depended-on symbols as hubs, with their dependent counts', async () => {
const body = await getJson('/api/entrypoints');
const hot = body.hubs.items.find((h: any) => h.name === 'hot');
expect(hot, 'the 500-caller function should top the hubs').toBeTruthy();
expect(hot.dependents).toBe(500);
expect(body.hubs.items[0].name).toBe('hot');
const counts = body.hubs.items.map((h: any) => h.dependents);
expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a));
// A file or a bare import is structure, not somewhere to start reading.
for (const hub of body.hubs.items) {
expect(['file', 'import', 'export', 'parameter']).not.toContain(hub.kind);
}
});
it('says a project without routes is not routed rather than failing', async () => {
const body = await getJson('/api/entrypoints');
expect(body.routes.routed).toBe(false);
expect(body.routes.items).toEqual([]);
expect(body.routes.routeCount).toBe(0);
});
it('honours limit, and keeps every list within it', async () => {
const body = await getJson('/api/entrypoints?limit=1');
expect(body.files.items.length).toBeLessThanOrEqual(1);
expect(body.hubs.items.length).toBe(1);
expect(body.hubs.total).toBeGreaterThanOrEqual(body.hubs.items.length);
const bad = await getStatusAndJson('/api/entrypoints?limit=0');
expect(bad.status).toBe(400);
expect(bad.body.code).toBe('bad-request');
});
});
describe('GET /api/nodes', () => {
it('answers a batch of ids in the order asked, and says which are missing', async () => {
const cacheId = await idOf('Cache', 'class');
const loadId = await idOf('load', 'method');
const body = await getJson(
`/api/nodes?id=${encodeURIComponent(loadId)}&id=${encodeURIComponent(cacheId)}&id=method%3Anot-a-real-id`
);
expect(body.items.map((n: any) => n.id)).toEqual([loadId, cacheId]);
expect(body.items[0].name).toBe('load');
expect(body.items[1].name).toBe('Cache');
expect(body.missing).toEqual(['method:not-a-real-id']);
// The REF shape, not the Symbol view payload: a trail redraws six names,
// not six rail sets.
expect(body.items[0].incoming).toBeUndefined();
expect(body.items[0].file).toBe('src/service.ts');
});
it('de-duplicates ids rather than answering twice', async () => {
const cacheId = await idOf('Cache', 'class');
const encoded = encodeURIComponent(cacheId);
const body = await getJson(`/api/nodes?id=${encoded}&id=${encoded}`);
expect(body.items).toHaveLength(1);
});
it('refuses an empty or oversized request with guidance', async () => {
const none = await getStatusAndJson('/api/nodes');
expect(none.status).toBe(400);
expect(none.body.hint).toContain('id=');
const ids = Array.from({ length: 61 }, (_, i) => `id=method%3A${i}`).join('&');
const many = await getStatusAndJson(`/api/nodes?${ids}`);
expect(many.status).toBe(400);
expect(many.body.error).toContain('Too many ids');
});
});
describe('an index that is not there', () => { describe('an index that is not there', () => {
it('answers with the same guidance the CLI prints, not a stack trace', async () => { it('answers with the same guidance the CLI prints, not a stack trace', async () => {
const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-')); const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-'));
+14
View File
@@ -168,6 +168,20 @@ Results panel under the input: 1px `--ink` border, max-height 420px; group heade
rows grid `18px | 1fr | auto`, 6px 10px, `--rule-faint` separators, selected/hover `--press`; name 12.5px mono + signature 11.5px mono rows grid `18px | 1fr | auto`, 6px 10px, `--rule-faint` separators, selected/hover `--press`; name 12.5px mono + signature 11.5px mono
`--ink-3` + location 11px mono. Flow grammar: "how does X reach Y", "X -> Y", "X → Y". `--ink-3` + location 11px mono. Flow grammar: "how does X reach Y", "X -> Y", "X → Y".
**As built (phase 1, CG-45).** Group headers are the result's KIND — `Methods`, `Functions`,
`Classes`, `Files` — a group appearing where its best result did, so flattening the groups
reproduces the ranking ↑/↓ walks. The prototype's two-group split (`Flow` / `Symbols & files`)
waits for the Flow view: a flow question is recognised now, but until there is a path to draw it
searches both endpoints and says so in one line above the results rather than offering a row that
lands on a placeholder. A file's row shows its basename with its DIRECTORY in the location column —
its name column already carries the path, and printing it twice reads as an error.
At rest — an empty box, or the empty screen — the panel shows **entry points** from
`/api/entrypoints`: routes (URL → handler), files that run something at module level (a CLI, a
worker entry, a script — ranked by calls × the number of other files they reach), and the most
depended-on symbols. Each section says what it is derived from, never that a file IS the entry
point.
## 4. Libraries and versions ## 4. Libraries and versions
- Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges, - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a
+88
View File
@@ -1987,6 +1987,94 @@ export class QueryBuilder {
return rows; 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 * 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 * 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); 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 * 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 * 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. * 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 * 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 * have to ask a follow-up question. Everything here is a *reader* of the
* existing schema; nothing indexes, resolves, or writes. * 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/stats what this index is and how much to trust it
* GET /api/search?q= the search palette * GET /api/search?q= the search palette
* GET /api/node/<id> the Symbol view: rails, members, tests, blast radius * 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/source?file=&from=&to= verbatim source, with a drift verdict
* GET /api/file/<path> the File view: outline and import rails * 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/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* * 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 { buildSource } from './source';
import { buildFile } from './file'; import { buildFile } from './file';
import { buildRoutes } from './routes'; import { buildRoutes } from './routes';
import { buildEntryPoints } from './entrypoints';
import { buildNodeRefs } from './nodes';
export { GraphSession } from './session'; export { GraphSession } from './session';
export { ApiError } from './respond'; export { ApiError } from './respond';
export * from './wire'; export * from './wire';
export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
export type { WireNodeRefs } from './nodes';
/** /**
* A mounted API, plus the handle it holds open. * 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/stats', description: 'Index state, graph counts, detected frameworks.' },
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] }, { 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/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', path: '/api/source',
description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.', 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/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/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); return ok(res, buildSearch(session.acquire(), ctx.query), ctx.method);
case '/api/routes': case '/api/routes':
return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method); 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': case '/api/source':
return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method); return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
default: 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 };
}
+10 -4
View File
@@ -9,11 +9,9 @@
import FlowView from './views/FlowView.svelte'; import FlowView from './views/FlowView.svelte';
import NotFoundView from './views/NotFoundView.svelte'; import NotFoundView from './views/NotFoundView.svelte';
import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte'; import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
import { trail } from './lib/trail.svelte'; import { trail, resolveTrailNames } from './lib/trail.svelte';
import { project } from './lib/project.svelte'; import { project } from './lib/project.svelte';
let query = $state('');
// One `/api/stats` for the whole app: the top bar's counts and the Symbol // One `/api/stats` for the whole app: the top bar's counts and the Symbol
// view's blast-radius denominator come out of the same payload. // view's blast-radius denominator come out of the same payload.
$effect(() => { $effect(() => {
@@ -37,6 +35,14 @@
}); });
}); });
// Hops restored from a URL carry ids and nothing else; one batched request
// turns the bar back into names. Runs after every trail change, and does
// nothing when every hop already has one.
$effect(() => {
void trail.hops.length;
void resolveTrailNames();
});
function isTypingTarget(target: EventTarget | null): boolean { function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
return ( return (
@@ -84,7 +90,7 @@
<svelte:window {onkeydown} /> <svelte:window {onkeydown} />
<TopBar bind:this={topbar} bind:query project={project.name} stats={project.summary} /> <TopBar bind:this={topbar} project={project.name} stats={project.summary} />
<TrailBar /> <TrailBar />
<main> <main>
{#if route.view === 'symbol'} {#if route.view === 'symbol'}
+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>
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
/**
* The results panel under the search box (design spec §3.7).
*
* It renders whatever `palette.view` is: the entry points when the box is
* empty, the ranked kind groups when it is not. The keyboard lives in
* `TopBar` (the keys are pressed in the input, not here) and arrives as the
* `selected` index; this component's only job beyond drawing is keeping that
* row in view when the selection moves past the panel's edge.
*/
import PaletteRows from './PaletteRows.svelte';
import { palette } from '../lib/palette.svelte';
import type { PaletteItem } from '../lib/search-model';
interface Props {
onpick: (item: PaletteItem) => void;
}
let { onpick }: Props = $props();
let panel: HTMLDivElement | null = $state(null);
let view = $derived(palette.view);
$effect(() => {
const index = palette.selected;
if (!panel) return;
const row = panel.querySelector(`[data-palette-row="${index}"]`);
row?.scrollIntoView({ block: 'nearest' });
});
</script>
<div class="panel" bind:this={panel} id="palette-panel" role="listbox" aria-label="Search results">
{#if view.hint}
<p class="hint">{view.hint}</p>
{/if}
<PaletteRows
palette={view}
selected={palette.selected}
rowRole="option"
{onpick}
onhover={(index) => palette.select(index)}
/>
{#if palette.failure}
<p class="note">{palette.failure}</p>
{:else if palette.pending && view.items.length === 0}
<p class="note">Searching…</p>
{:else if view.empty}
<p class="note">{view.empty}</p>
{/if}
</div>
<style>
.panel {
position: absolute;
z-index: 40;
top: 32px;
right: 0;
left: 0;
max-height: 420px;
overflow: auto;
background: var(--paper);
border: 1px solid var(--ink);
}
.hint {
margin: 0;
padding: 8px 10px;
border-bottom: 1px solid var(--rule-faint);
background: var(--paper-2);
color: var(--ink-2);
font-size: 12px;
}
.note {
margin: 0;
padding: 8px 10px;
color: var(--ink-3);
font-size: 12px;
}
</style>
+79 -9
View File
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import { router, mapHref, flowHref, symbolHref } from '../lib/router.svelte'; import { router, mapHref, flowHref, symbolHref } from '../lib/router.svelte';
import { trail } from '../lib/trail.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 { interface Props {
/** Indexed project name, e.g. "codegraph/". Null until stats load. */ /** Indexed project name, e.g. "codegraph/". Null until stats load. */
project?: string | null; project?: string | null;
/** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */ /** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */
stats?: string | null; 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); let input: HTMLInputElement | null = $state(null);
@@ -31,13 +31,75 @@
export function focusSearch(): void { export function focusSearch(): void {
input?.focus(); input?.focus();
input?.select(); 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) { 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> </script>
<svelte:window {onpointerdown} />
<header class="topbar"> <header class="topbar">
<a class="brand" href="#/" aria-label="CodeGraph home"> <a class="brand" href="#/" aria-label="CodeGraph home">
<span class="brand-mark" aria-hidden="true"></span> <span class="brand-mark" aria-hidden="true"></span>
@@ -51,19 +113,27 @@
<a href={flowHref()} class:active={view === 'flow'}>Flow</a> <a href={flowHref()} class:active={view === 'flow'}>Flow</a>
</nav> </nav>
<div class="search" role="search"> <div class="search" role="search" bind:this={searchBox}>
<input <input
bind:this={input} bind:this={input}
bind:value={query} bind:value={palette.query}
{onkeydown} {onkeydown}
onfocus={() => palette.show()}
id="q" id="q"
type="search" type="search"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
placeholder={'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus'} placeholder={'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus'}
aria-label="Search symbols and files" 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>
<div class="project" title="Indexed project"> <div class="project" title="Indexed project">
+28 -3
View File
@@ -3,6 +3,14 @@
import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte'; import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
import { navigate, symbolHref, flowHref } from '../lib/router.svelte'; import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
/**
* "Read as flow" replays the trail as a computed path in the Flow view,
* which is phase 2 (CG-50). The control is built and wired; it stays hidden
* until there is a view to send it to, because a button that lands on a
* placeholder is worse than no button.
*/
const READ_AS_FLOW = false;
let hops = $derived(trail.hops); let hops = $derived(trail.hops);
function step(index: number) { function step(index: number) {
@@ -17,9 +25,23 @@
navigate(flowHref(encodeTrail(hops))); navigate(flowHref(encodeTrail(hops)));
} }
/**
* Clear the path, keep the place.
*
* Emptying the trail while you are reading a symbol would also throw the
* symbol away, which is not what "Clear" says. It restarts the trail at
* where you are — one `start` hop — and only leaves for the empty screen
* when there is nowhere to stay.
*/
function clear() { function clear() {
const here = trail.current;
trail.clear(); trail.clear();
navigate('#/'); if (!here) {
navigate('#/');
return;
}
trail.push({ id: here.id, name: here.name, kind: here.kind, dir: 'start' });
navigate(symbolHref(here.id, { trail: encodeTrail(trail.hops) }), { replace: true });
} }
</script> </script>
@@ -27,7 +49,10 @@
<span class="label">Trail</span> <span class="label">Trail</span>
{#if hops.length === 0} {#if hops.length === 0}
<span class="empty">Follow a call and the path you walked shows up here.</span> <span class="empty"
>Step into a call on the right, or up to a caller on the left — the trail records the
path.</span
>
{:else} {:else}
{#each hops as hop, i (hop.id)} {#each hops as hop, i (hop.id)}
{#if i > 0} {#if i > 0}
@@ -59,7 +84,7 @@
<span class="spacer"></span> <span class="spacer"></span>
{#if hops.length > 1} {#if READ_AS_FLOW && hops.length > 1}
<button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button> <button type="button" class="tb-btn" onclick={readAsFlow}>Read as flow</button>
{/if} {/if}
{#if hops.length > 0} {#if hops.length > 0}
@@ -77,6 +77,7 @@
class="row" class="row"
class:origin={isOrigin} class:origin={isOrigin}
class:sel={railFocus.at('left', indexOf(groupIndex, rowIndex))} class:sel={railFocus.at('left', indexOf(groupIndex, rowIndex))}
data-target={node.id}
role="button" role="button"
tabindex="0" tabindex="0"
title={rowTitle(row)} title={rowTitle(row)}
+88
View File
@@ -163,6 +163,67 @@ export interface WireBlastScale {
estimated: boolean; estimated: boolean;
} }
/* ------------------------------------------------------- search palette -- */
/** How a result's text matched the query — the server's primary sort key. */
export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
export interface WireSearchResult extends WireNodeRef {
matchKind: MatchKind;
}
export interface WireSearchGroup {
kind: NodeKind;
count: number;
items: WireSearchResult[];
}
export interface WireSearch {
query: string;
/** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
text: string;
filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
results: WireList<WireSearchResult>;
/** Kind buckets in ranked order — flattening them reproduces the ranking. */
groups: WireSearchGroup[];
}
export interface WireNodeRefs {
items: WireNodeRef[];
/** Ids that name nothing in this index — a stale link, not an error. */
missing: string[];
}
/* ---------------------------------------------------------- entry points -- */
export interface WireEntryRoute {
url: string;
handler: string;
file: string;
line: number;
handlerId: string | null;
}
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 {
dependents: number;
}
export interface WireEntryPoints {
routes: { routed: boolean; routeCount: number; items: WireEntryRoute[] };
/** `total` is a floor on both lists — the server counts what its scan saw. */
files: WireList<WireEntryFile>;
hubs: WireList<WireEntryHub>;
}
export interface WireStats { export interface WireStats {
project: { root: string; name: string }; project: { root: string; name: string };
index: { index: {
@@ -257,6 +318,33 @@ export function fetchSymbol(id: string, signal?: AbortSignal): Promise<WireSymbo
return getJson<WireSymbolPayload>(`api/node/${encoded}`, signal); return getJson<WireSymbolPayload>(`api/node/${encoded}`, signal);
} }
export function fetchSearch(
query: string,
opts: { limit?: number } = {},
signal?: AbortSignal
): Promise<WireSearch> {
const params = new URLSearchParams({ q: query });
if (opts.limit) params.set('limit', String(opts.limit));
return getJson<WireSearch>(`api/search?${params}`, signal);
}
/** Names and locations for ids you already have — what the trail redraws with. */
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
const params = new URLSearchParams();
for (const id of ids) params.append('id', id);
return getJson<WireNodeRefs>(`api/nodes?${params}`, signal);
}
export function fetchEntryPoints(
opts: { limit?: number } = {},
signal?: AbortSignal
): Promise<WireEntryPoints> {
const params = new URLSearchParams();
if (opts.limit) params.set('limit', String(opts.limit));
const query = params.toString();
return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
}
export function fetchSource( export function fetchSource(
file: string, file: string,
from: number, from: number,
+191
View File
@@ -0,0 +1,191 @@
/**
* The search palette's live state.
*
* Everything that decides *what* is on screen lives in `search-model.ts` as
* plain functions; this module only owns the parts that need time: the debounce
* that keeps a fast typist from firing a request per keystroke, the abort that
* throws away an answer to a query nobody is asking any more, and the selection
* the ↑/↓ keys move.
*
* The entry points are fetched once and kept — they describe the index, not the
* query — so the palette has something to show the instant it opens.
*/
import { fetchEntryPoints, fetchSearch, type WireEntryPoints, type WireSearch } from './api';
import {
buildEntryPalette,
buildSearchPalette,
moveSelection,
parseFlowQuery,
type Palette,
type PaletteItem,
} from './search-model';
/** Results asked of the server for one search. */
const SEARCH_LIMIT = 40;
/**
* Entry-point rows fetched, and how many of them the palette shows.
*
* One fetch serves both readers: the palette wants a short list under the box,
* the empty screen wants the long one. Fetching the long list and slicing is a
* request saved and — more to the point — keeps the two lists in the same
* order, which they would not be if they were two answers taken at two times.
*/
const ENTRY_LIMIT = 24;
export const PALETTE_ENTRY_ROWS = 6;
/**
* Milliseconds of quiet before a query is sent.
*
* The server answers a search in single-digit milliseconds on this repo's own
* index, so this is not about protecting it — it is about not showing three
* different result sets while a word is still being typed.
*/
const DEBOUNCE_MS = 90;
let query = $state('');
let open = $state(false);
let selected = $state(0);
let loading = $state(false);
let failure = $state<string | null>(null);
let answers = $state<WireSearch[]>([]);
let entries = $state<WireEntryPoints | null>(null);
let inflight: AbortController | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
/** Guards against an older answer landing after a newer one. */
let generation = 0;
let entriesInflight: Promise<void> | null = null;
function loadEntries(): Promise<void> {
if (entriesInflight) return entriesInflight;
entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT })
.then((value) => {
entries = value;
})
.catch(() => {
// The palette still works without them; a failed "where do I start"
// should never stop someone from typing a name.
entries = null;
});
return entriesInflight;
}
function cancel(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
inflight?.abort();
inflight = null;
}
async function run(text: string, mine: number): Promise<void> {
const flow = parseFlowQuery(text);
const controller = new AbortController();
inflight = controller;
loading = true;
try {
const queries = flow ? [flow.from, flow.to] : [text];
const results = await Promise.all(
queries.map((q) => fetchSearch(q, { limit: SEARCH_LIMIT }, controller.signal))
);
if (mine !== generation) return;
answers = results;
failure = null;
} catch (cause) {
if (controller.signal.aborted || mine !== generation) return;
answers = [];
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
if (mine === generation) loading = false;
}
}
function schedule(text: string): void {
cancel();
const mine = (generation += 1);
if (text.trim() === '') {
answers = [];
failure = null;
loading = false;
return;
}
timer = setTimeout(() => {
timer = null;
void run(text, mine);
}, DEBOUNCE_MS);
}
/** The palette as it should be drawn right now. */
function current(): Palette {
if (query.trim() === '') return buildEntryPalette(entries, { perSection: PALETTE_ENTRY_ROWS });
return buildSearchPalette(answers, parseFlowQuery(query));
}
export const palette = {
get query(): string {
return query;
},
set query(next: string) {
if (next === query) return;
query = next;
selected = 0;
schedule(next);
},
get open(): boolean {
return open;
},
get loading(): boolean {
return loading;
},
get failure(): string | null {
return failure;
},
get view(): Palette {
return current();
},
get selected(): number {
return selected;
},
get selectedItem(): PaletteItem | null {
const items = current().items;
return items[Math.min(selected, items.length - 1)] ?? null;
},
/** True while a typed query has no answer yet — the panel says so. */
get pending(): boolean {
return query.trim() !== '' && (loading || timer !== null);
},
show(): void {
open = true;
void loadEntries();
},
hide(): void {
open = false;
},
select(index: number): void {
selected = index;
},
move(delta: number): void {
selected = moveSelection(selected, delta, current().items.length);
},
/** Close and empty the box — what picking a result leaves behind. */
reset(): void {
cancel();
generation += 1;
query = '';
answers = [];
failure = null;
loading = false;
selected = 0;
open = false;
},
/** Load the entry points without opening the panel (the empty screen wants them). */
ensureEntries: loadEntries,
get entries(): WireEntryPoints | null {
return entries;
},
};
+266
View File
@@ -0,0 +1,266 @@
/**
* What the search palette decides, without a browser.
*
* The panel under the input is a flat keyboard list drawn as groups: ↑/↓ walk
* every row in ranked order, and the group headers are captions on top of that
* order rather than a second axis to navigate. So the model here is one
* function — {@link buildPalette} — that turns whatever the palette has (a
* search answer, two of them for a flow question, or the entry points it shows
* when the box is empty) into `sections` for rendering and `items` for the
* keyboard, with `items` being exactly the concatenation of the sections' rows.
*
* Tested in `__tests__/ui-search-model.test.ts`.
*/
import type {
WireEntryPoints,
WireNodeRef,
WireSearch,
WireSearchResult,
} from './api';
import { basename, plural } from './symbol-model';
/* ------------------------------------------------------------ flow query -- */
export interface FlowQuery {
from: string;
to: string;
}
/**
* "how does X reach Y", "X -> Y", "X → Y".
*
* Phase 1 has no Flow view to send this to, but the question is worth
* recognising anyway: someone who types it gets both endpoints looked up
* instead of a search for the whole sentence, which matches nothing. CG-50
* turns the same parse into a computed path.
*/
const FLOW_SENTENCE =
/^\s*(?:how\s+(?:does|do|would|can)\s+)?([\w$.]+)\s+(?:reach|reaches|call|calls|hit|hits|get\s+to|end\s+up\s+(?:in|at))\s+([\w$.]+)\s*\??\s*$/i;
const FLOW_ARROW = /^\s*([\w$.]+)\s*(?:->|→|=>)\s*([\w$.]+)\s*\??\s*$/;
/** The last dotted segment: `Service.load` asks about `load`. */
function lastSegment(name: string): string {
const cut = name.lastIndexOf('.');
return cut < 0 ? name : name.slice(cut + 1);
}
export function parseFlowQuery(query: string): FlowQuery | null {
const match = FLOW_SENTENCE.exec(query) ?? FLOW_ARROW.exec(query);
if (!match) return null;
const from = lastSegment(match[1] as string);
const to = lastSegment(match[2] as string);
if (!from || !to || from === to) return null;
return { from, to };
}
/* ----------------------------------------------------------------- rows -- */
export type PaletteItem =
| { type: 'symbol'; id: string; node: WireNodeRef; name: string; meta: string; location: string }
| { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null };
export interface PaletteSection {
/** Sentence-case caption, e.g. "Methods", "Files that run something". */
title: string;
/** A second line under the caption, when the group needs explaining. */
note?: string;
items: PaletteItem[];
}
export interface Palette {
sections: PaletteSection[];
/** Every row, in the order ↑/↓ walks them. */
items: PaletteItem[];
/** A sentence above the sections — the flow-question note, when there is one. */
hint: string | null;
/** Nothing to show, and why. Null when there is something. */
empty: string | null;
}
/** Plural caption for a kind bucket: "Methods", "Type aliases", "Files". */
export function kindGroupTitle(kind: string, count: number): string {
const word = kind.replace(/_/g, ' ');
const many = word.endsWith('s') ? `${word}es` : `${word}s`;
const title = count === 1 ? word : many;
return title.charAt(0).toUpperCase() + title.slice(1);
}
/**
* `tools.ts:412` — where the symbol is, short enough for the right column.
*
* A file's location is its DIRECTORY, because its name column is already the
* basename: printing the path twice tells a reader nothing and pushes the row
* past the panel's width on any deeply-nested file.
*/
export function locationOf(node: WireNodeRef): string {
if (node.kind !== 'file') return `${basename(node.file)}:${node.line}`;
const cut = node.file.lastIndexOf('/');
return cut < 0 ? 'project root' : node.file.slice(0, cut);
}
function symbolItem(node: WireNodeRef, meta = ''): PaletteItem {
return {
type: 'symbol',
id: node.id,
node,
name: node.kind === 'file' ? basename(node.file) : node.name,
meta: meta || signatureOf(node),
location: locationOf(node),
};
}
/** The signature, trimmed to something that fits one row. */
function signatureOf(node: WireNodeRef): string {
if (!node.signature) return '';
const oneLine = node.signature.replace(/\s+/g, ' ').trim();
return oneLine.length > 72 ? `${oneLine.slice(0, 71)}…` : oneLine;
}
/* --------------------------------------------------------------- search -- */
/**
* Interleave two answers, keeping each one's rank.
*
* A flow question names two symbols and both matter equally, so taking the
* first of each before the second of either is the only merge that does not
* quietly rank one endpoint above the other. Duplicates (a symbol that matched
* both halves) keep their earliest position.
*/
export function interleaveResults(
a: readonly WireSearchResult[],
b: readonly WireSearchResult[]
): WireSearchResult[] {
const merged: WireSearchResult[] = [];
const seen = new Set<string>();
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
for (const list of [a, b]) {
const item = list[i];
if (item && !seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
}
return merged;
}
/**
* Group results by kind, a group appearing where its best result did.
*
* The same rule the server uses, re-applied here because a flow question merges
* two answers and the merged order is not the order either of them shipped.
*/
export function groupByKind(results: readonly WireSearchResult[]): PaletteSection[] {
const sections: PaletteSection[] = [];
const byKind = new Map<string, PaletteSection>();
for (const result of results) {
let section = byKind.get(result.kind);
if (!section) {
section = { title: '', items: [] };
byKind.set(result.kind, section);
sections.push(section);
}
section.items.push(symbolItem(result));
}
for (const [kind, section] of byKind) section.title = kindGroupTitle(kind, section.items.length);
return sections;
}
export function buildSearchPalette(
answers: readonly WireSearch[],
flow: FlowQuery | null
): Palette {
const results =
answers.length > 1
? interleaveResults(answers[0]?.results.items ?? [], answers[1]?.results.items ?? [])
: answers[0]?.results.items ?? [];
const sections = groupByKind(results);
const items = sections.flatMap((section) => section.items);
const hint = flow
? `Reading the path between two symbols arrives with the Flow view. Here is what ${flow.from} and ${flow.to} name.`
: null;
return {
sections,
items,
hint,
empty: items.length === 0 ? 'No symbol or file in the index matches that.' : null,
};
}
/* ---------------------------------------------------------- entry points -- */
/**
* Where to start reading — the palette's resting state and the empty screen.
*
* The three sections say what they are derived from rather than asserting that
* a file IS the entry point: "runs something at module level" is a fact about
* the graph, "this is the main file" would be a guess.
*/
export function buildEntryPalette(
entries: WireEntryPoints | null,
opts: { perSection?: number } = {}
): Palette {
if (!entries) return { sections: [], items: [], hint: null, empty: null };
const cap = opts.perSection ?? Number.POSITIVE_INFINITY;
const take = <T>(items: readonly T[]): T[] =>
Number.isFinite(cap) ? items.slice(0, cap) : [...items];
const sections: PaletteSection[] = [];
if (entries.routes.routed && entries.routes.items.length > 0) {
sections.push({
title: 'Routes',
note: 'A request from outside arrives here.',
items: take(entries.routes.items).map((route) => ({
type: 'route' as const,
id: `route:${route.url}:${route.file}:${route.line}`,
url: route.url,
handler: route.handler,
location: `${basename(route.file)}:${route.line}`,
nodeId: route.handlerId,
})),
});
}
if (entries.files.items.length > 0) {
sections.push({
title: 'Files that run something',
note: 'Statements at the top level of the file — a CLI, a worker entry, a script.',
items: take(entries.files.items).map((file) =>
symbolItem(file, `${plural(file.calls, 'call')} at module level · reaches ${plural(file.reaches, 'file')}`)
),
});
}
if (entries.hubs.items.length > 0) {
sections.push({
title: 'Most depended on',
note: 'The symbols a change radiates furthest from.',
items: take(entries.hubs.items).map((hub) =>
symbolItem(hub, `${plural(hub.dependents, 'dependent')}`)
),
});
}
return {
sections,
items: sections.flatMap((section) => section.items),
hint: null,
empty:
sections.length === 0
? 'This index has no routes, no file that runs anything, and nothing depended on yet.'
: null,
};
}
/* ------------------------------------------------------------- keyboard -- */
/** Wrap-around ↑/↓ over the flat item list. */
export function moveSelection(index: number, delta: number, length: number): number {
if (length === 0) return 0;
return (((index + delta) % length) + length) % length;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* The trail's wire format — the part with no state in it.
*
* Split out of `trail.svelte.ts` so it can be tested without a Svelte runtime:
* the round-trip through the URL is the whole reason the trail is shareable,
* and it is the one part of the trail that can be silently wrong.
*
* Encoding: comma-separated tokens, each `<dir><encoded id>` where dir is
* `s` (start) | `d` (stepped down, into a call) | `u` (stepped up, to a
* caller). The dir char is ALWAYS present — an id may itself begin with 'd'
* or 'u' (`union:…`), so an optional prefix would be ambiguous.
*/
export type HopDirection = 'start' | 'down' | 'up';
export interface TrailHop {
id: string;
/** null until the node is fetched; render `hopLabel()` rather than this. */
name: string | null;
kind: string | null;
dir: HopDirection;
}
const DIR_TO_CHAR: Record<HopDirection, string> = { start: 's', down: 'd', up: 'u' };
const CHAR_TO_DIR: Record<string, HopDirection> = { s: 'start', d: 'down', u: 'up' };
/**
* A readable stand-in for a hop whose name has not been resolved yet.
*
* Only ever seen for a moment: a cold load asks `/api/nodes` for the names of
* every hop it restored from the URL. It still has to be readable, because a
* slow answer would otherwise put a 32-character hash in the trail bar.
*/
export function hopLabel(hop: TrailHop): string {
if (hop.name) return hop.name;
const body = hop.id.includes(':') ? hop.id.slice(hop.id.indexOf(':') + 1) : hop.id;
// Path-shaped ids (`file:src/mcp/tools.ts`) read best as their basename.
const basename = body.slice(body.lastIndexOf('/') + 1);
if (basename.length === 0 || basename.length > 40) return `${body.slice(0, 8)}…`;
// A content hash is not a name: shown whole it is a wall of hex wide enough
// to push the rest of the trail off screen.
if (/^[0-9a-f]{16,}$/.test(basename)) return `${basename.slice(0, 8)}…`;
return basename;
}
export function encodeTrail(hops: readonly TrailHop[]): string {
return hops.map((h) => DIR_TO_CHAR[h.dir] + encodeURIComponent(h.id)).join(',');
}
export function decodeTrail(encoded: string | null): TrailHop[] {
if (!encoded) return [];
const hops: TrailHop[] = [];
for (const token of encoded.split(',')) {
if (token.length < 2) continue;
const dir = CHAR_TO_DIR[token[0] as string];
if (!dir) continue;
let id: string;
try {
id = decodeURIComponent(token.slice(1));
} catch {
id = token.slice(1);
}
if (id) hops.push({ id, name: null, kind: null, dir });
}
return hops;
}
+62 -50
View File
@@ -4,60 +4,38 @@
* Hops live in memory (they carry names and kinds, which the URL cannot), * Hops live in memory (they carry names and kinds, which the URL cannot),
* and are mirrored into the `t` query param so a reload or a shared link * and are mirrored into the `t` query param so a reload or a shared link
* still reproduces the walk. On a cold load only the ids survive; names are * still reproduces the walk. On a cold load only the ids survive; names are
* filled in by `resolve()` as each hop's node is fetched. * filled in by `resolve()` as each hop's node is fetched. The wire format
* * itself lives in `trail-codec.ts`, where it can be tested without a runtime.
* Encoding: comma-separated tokens, each `<dir><encoded id>` where dir is
* `s` (start) | `d` (stepped down, into a call) | `u` (stepped up, to a
* caller). The dir char is ALWAYS present — an id may itself begin with 'd'
* or 'u' (`union:…`), so an optional prefix would be ambiguous.
*/ */
export type HopDirection = 'start' | 'down' | 'up'; import { fetchNodeRefs } from './api';
import { encodeTrail, decodeTrail, type HopDirection, type TrailHop } from './trail-codec';
export interface TrailHop { export { encodeTrail, decodeTrail, hopLabel } from './trail-codec';
id: string; export type { HopDirection, TrailHop } from './trail-codec';
/** null until the node is fetched; render `hopLabel()` rather than this. */
name: string | null;
kind: string | null;
dir: HopDirection;
}
const DIR_TO_CHAR: Record<HopDirection, string> = { start: 's', down: 'd', up: 'u' };
const CHAR_TO_DIR: Record<string, HopDirection> = { s: 'start', d: 'down', u: 'up' };
/** A readable stand-in for a hop whose name has not been resolved yet. */
export function hopLabel(hop: TrailHop): string {
if (hop.name) return hop.name;
const body = hop.id.includes(':') ? hop.id.slice(hop.id.indexOf(':') + 1) : hop.id;
// Path-shaped ids (`file:src/mcp/tools.ts`) read best as their basename.
const basename = body.slice(body.lastIndexOf('/') + 1);
return basename.length > 0 && basename.length <= 40 ? basename : `${body.slice(0, 8)}…`;
}
export function encodeTrail(hops: readonly TrailHop[]): string {
return hops.map((h) => DIR_TO_CHAR[h.dir] + encodeURIComponent(h.id)).join(',');
}
export function decodeTrail(encoded: string | null): TrailHop[] {
if (!encoded) return [];
const hops: TrailHop[] = [];
for (const token of encoded.split(',')) {
if (token.length < 2) continue;
const dir = CHAR_TO_DIR[token[0] as string];
if (!dir) continue;
let id: string;
try {
id = decodeURIComponent(token.slice(1));
} catch {
id = token.slice(1);
}
if (id) hops.push({ id, name: null, kind: null, dir });
}
return hops;
}
let hops = $state<TrailHop[]>([]); let hops = $state<TrailHop[]>([]);
/**
* Every name this session has learned, by id.
*
* The hop objects cannot carry it: truncating the trail throws them away, and
* walking back through history rebuilds the dropped hops from the URL, which
* holds ids and nothing else. Without this cache the bar would re-fetch — or,
* worse, redraw a hash for a symbol it had already named a second ago.
*/
const known = new Map<string, { name: string | null; kind: string | null }>();
function remember(id: string, info: { name?: string | null; kind?: string | null }): void {
// Nothing to remember is not an entry: an empty one would read as "already
// known" and stop the bar from ever asking for the name.
if (!info.name && !info.kind) return;
const at = known.get(id) ?? { name: null, kind: null };
if (info.name) at.name = info.name;
if (info.kind) at.kind = info.kind;
known.set(id, at);
}
export const trail = { export const trail = {
get hops(): readonly TrailHop[] { get hops(): readonly TrailHop[] {
return hops; return hops;
@@ -75,6 +53,7 @@ export const trail = {
* loop — the trail is a path, not a history. * loop — the trail is a path, not a history.
*/ */
push(hop: { id: string; name?: string | null; kind?: string | null; dir?: HopDirection }): void { push(hop: { id: string; name?: string | null; kind?: string | null; dir?: HopDirection }): void {
remember(hop.id, hop);
const existing = hops.findIndex((h) => h.id === hop.id); const existing = hops.findIndex((h) => h.id === hop.id);
if (existing >= 0) { if (existing >= 0) {
hops = hops.slice(0, existing + 1); hops = hops.slice(0, existing + 1);
@@ -102,6 +81,7 @@ export const trail = {
/** Fill in the name/kind of a hop once its node has been fetched. */ /** Fill in the name/kind of a hop once its node has been fetched. */
resolve(id: string, info: { name?: string | null; kind?: string | null }): void { resolve(id: string, info: { name?: string | null; kind?: string | null }): void {
remember(id, info);
const hop = hops.find((h) => h.id === id); const hop = hops.find((h) => h.id === id);
if (!hop) return; if (!hop) return;
if (info.name) hop.name = info.name; if (info.name) hop.name = info.name;
@@ -116,11 +96,43 @@ export const trail = {
hydrate(encoded: string | null): void { hydrate(encoded: string | null): void {
const decoded = decodeTrail(encoded); const decoded = decodeTrail(encoded);
if (encodeTrail(decoded) === encodeTrail(hops)) return; if (encodeTrail(decoded) === encodeTrail(hops)) return;
// Keep any names already resolved for ids that survive the change. // Names survive the change — including for hops this trail dropped earlier
const known = new Map(hops.filter((h) => h.name).map((h) => [h.id, h])); // and history has just brought back.
hops = decoded.map((h) => { hops = decoded.map((h) => {
const seen = known.get(h.id); const seen = known.get(h.id);
return seen ? { ...h, name: seen.name, kind: seen.kind } : h; return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
}); });
}, },
}; };
/**
* Give the hops restored from a URL their names back.
*
* A trail travels as ids, so a shared or reloaded link arrives with every hop
* but the one on screen unnamed — and `hopLabel` then draws a hash. One batched
* request fixes the whole bar. Ids that name nothing are marked resolved with
* the label they already had, so a stale link asks once and not on every
* re-render.
*/
const nameless = new Set<string>();
export async function resolveTrailNames(): Promise<void> {
const unknown = hops
.filter((hop) => !hop.name && !known.get(hop.id)?.name && !nameless.has(hop.id))
.map((hop) => hop.id);
if (unknown.length === 0) return;
try {
const { items, missing } = await fetchNodeRefs(unknown);
for (const node of items) {
trail.resolve(node.id, {
name: node.kind === 'file' ? node.file : node.name,
kind: node.kind,
});
}
// An id this index does not hold is a stale link. Recorded so the bar asks
// once rather than on every redraw.
for (const id of missing) nameless.add(id);
} catch {
// A name is a nicety; the hop still navigates without one.
}
}
+62
View File
@@ -1,8 +1,40 @@
<script lang="ts"> <script lang="ts">
/**
* The empty screen — and the answer to "where do I start".
*
* Nothing selected is the normal first state of a viewer opened on a project
* nobody has read before, so it carries the same entry points the palette
* shows at rest, at full length: the routes a request arrives on, the files
* that run something at module level, and the symbols the most code depends
* on. Every one of them is derived from the graph — see
* `src/ui-server/api/entrypoints.ts` for what each is derived from.
*/
import PaletteRows from '../components/PaletteRows.svelte';
import { palette } from '../lib/palette.svelte';
import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
import { walkTo } from '../lib/walk';
interface Props { interface Props {
project?: string | null; project?: string | null;
} }
let { project = null }: Props = $props(); let { project = null }: Props = $props();
$effect(() => {
void palette.ensureEntries();
});
let entries = $derived(buildEntryPalette(palette.entries));
function pick(item: PaletteItem) {
const id = item.type === 'route' ? item.nodeId : item.id;
if (!id) return;
walkTo(
item.type === 'route'
? { id, name: item.handler, kind: null }
: { id, name: item.node.name, kind: item.node.kind },
'start'
);
}
</script> </script>
<div class="scroll"> <div class="scroll">
@@ -17,6 +49,15 @@
what it calls on the right — each callee lined up with the line that makes the call. what it calls on the right — each callee lined up with the line that makes the call.
</p> </p>
</div> </div>
{#if entries.sections.length > 0}
<section class="entries" aria-label="Where to start">
<h3>Where to start</h3>
<div class="rows">
<PaletteRows palette={entries} onpick={pick} />
</div>
</section>
{/if}
</div> </div>
<style> <style>
@@ -24,4 +65,25 @@
height: 100%; height: 100%;
overflow: auto; overflow: auto;
} }
/* `.emptystate` itself is global (app.css) and shared with the other views;
only its bottom padding changes here, to sit against the list below. */
.scroll :global(.emptystate) {
padding-bottom: 8px;
}
.entries {
max-width: 720px;
padding: 8px 40px 48px;
}
.entries h3 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
.rows {
border: 1px solid var(--rule-soft);
}
</style> </style>