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>
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/**
|
|
* 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…');
|
|
});
|
|
});
|