feat(ui): implement Map grouping, dependents, and weight bars; symbol tab address

Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
This commit is contained in:
Colby McHenry
2026-08-31 23:49:39 -05:00
parent b9ca4b7981
commit 7ec9ef1818
21 changed files with 905 additions and 41 deletions
+131 -2
View File
@@ -24,7 +24,13 @@ import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map';
import {
moduleIdFor,
normalizeRoot,
pickDefaultDepth,
pickDefaultRoot,
resetMapCache,
} from '../src/ui-server/api/map';
let server: UiServerHandle;
let api: GraphApi;
@@ -293,6 +299,102 @@ describe('pickDefaultRoot', () => {
});
});
describe('pickDefaultDepth', () => {
/** `n` files under `dir`, each carrying `each` symbols. */
function spread(dir: string, n: number, each: number, test = false) {
return Array.from({ length: n }, (_, i) => ({
path: `${dir}/f${i}.ts`,
symbols: each,
test,
}));
}
it('goes deeper when one box holds the program', () => {
// The shape this rule exists for: a React-Native-ish repo whose whole app
// is under `src/`. At depth 1 the map is a box labelled `src` and nothing
// else — 285 files and two thirds of the symbols, unopenable.
const files = [
...spread('src/components', 119, 12),
...spread('src/app', 53, 21),
...spread('src/api', 47, 7),
...spread('src/utils', 24, 6),
...spread('ios/CaptureView', 63, 28),
...spread('ios/Camera', 3, 38),
...spread('.github/workflows', 4, 0),
];
expect(pickDefaultDepth(files, '')).toBe(2);
});
it('keeps a repository whose directories ARE its modules at one level', () => {
const files = [
...spread('src/db', 8, 40),
...spread('src/graph', 9, 40),
...spread('src/mcp', 7, 40),
...spread('src/search', 5, 40),
...spread('src/sync', 4, 40),
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
it('does not open a dominant box that has nothing in it', () => {
// `src/core` holds most of the symbols but only three files: this is a
// small project honestly drawn, not a coarse grouping.
const files = [
...spread('src/core', 3, 90),
...spread('src/db', 2, 10),
...spread('src/api', 2, 10),
{ path: 'src/index.ts', symbols: 5, test: false },
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
it('keeps going while the picture is still one box', () => {
// `frontend/` then `frontend/src/` — two levels of packaging before the
// code. Neither is a map; the third level is.
const files = [
...spread('frontend/src/screens', 15, 10),
...spread('frontend/src/components', 14, 10),
...spread('frontend/src/hooks', 8, 10),
...spread('frontend/src/api', 6, 10),
...spread('backend/app', 5, 8),
];
expect(pickDefaultDepth(files, '')).toBe(3);
});
it('stops before a deeper grouping becomes a crowd', () => {
const files = [
...spread('src/a', 30, 10),
...Array.from({ length: 70 }, (_, i) => ({
path: `src/b/m${i}/f.ts`,
symbols: 1,
test: false,
})),
];
// Depth 2 is dominated by `src/a`, but depth 3 would draw 71 boxes.
expect(pickDefaultDepth(files, '')).toBe(2);
});
it('does not chase a tree that has no more levels to give', () => {
const files = [
...spread('src/a', 30, 10),
...spread('src/b', 2, 1),
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
it('counts only the modules the map draws by default', () => {
// Test files are hidden unless the reader asks for them, so a depth that
// is only "enough boxes" once tests are counted is not enough boxes.
const files = [
...spread('src/app', 40, 10),
...spread('src/__tests__/a', 12, 10, true),
...spread('src/__tests__/b', 12, 10, true),
...spread('src/__tests__/c', 12, 10, true),
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
});
describe('GET /api/map', () => {
it('is listed by the API index', async () => {
const res = await request('/api');
@@ -407,13 +509,40 @@ describe('GET /api/map', () => {
// down joins that level's bucket rather than being promoted to a module.
expect(ids).toContain('src/core/passes');
expect(ids).toContain('src/core/(root files)');
expect(ids).toContain('src/api/(root files)');
expect(ids).not.toContain('src/core');
// …but the bucket keeps its name only because `src/core/passes` sits beside
// it. `src/api` has nothing below it, so its bucket IS `src/api` and saying
// otherwise would name a directory the repository does not have.
expect(ids).toContain('src/api');
expect(ids).not.toContain('src/api/(root files)');
const slashed = await getMap('?root=src%2F&depth=2');
expect(slashed.modules).toEqual(deep.modules);
});
it('counts the files outside each module that reference into it', async () => {
const map = await getMap('?root=src&depth=1');
const by = new Map<string, any>(map.modules.map((m: any) => [m.id, m]));
// `src/types.ts` and `src/index.ts` are what the rest of the fixture
// imports, so the bucket holding types is the most depended-on box.
const types = by.get('src/(root files)');
expect(types.dependents.files).toBeGreaterThan(0);
expect(types.dependents.modules).toBeGreaterThan(1);
// Every count is FILES OUTSIDE the module: never more than the rest of the
// repository, and a module's own internal imports never inflate it.
const total = map.modules.reduce((sum: number, m: any) => sum + m.files, 0);
for (const module of map.modules) {
expect(module.dependents.files).toBeLessThanOrEqual(total - module.files);
expect(module.dependents.modules).toBeLessThanOrEqual(map.modules.length - 1);
// A module nothing arrives at is an island, and the two must agree —
// they are computed from different queries and a reader sees both.
const arrives = map.links.some((l: any) => l.target === module.id);
if (!arrives) expect(module.dependents.files).toBe(0);
}
});
it('rejects an out-of-range depth as JSON, not as a crash', async () => {
const res = await request('/api/map?depth=9');
expect(res.status).toBe(400);