From bd99c5e99a3e00b8996a69970dbebf8b35d9dbb2 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 27 Aug 2026 03:47:42 -0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20the=20whole=20file=20=E2=80=94=20fu?= =?UTF-8?q?ll=20source=20with=20gutter=20ports=20and=20intra-file=20call?= =?UTF-8?q?=20arcs=20(CG-52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The File view gains a Source tab: the file itself, top to bottom, with the Symbol view's line grid, gutter ports and call-site links, a line-anchored callee rail, and — in the left margin — an arc for every call that stays inside the file, drawn from the calling line to the callee's definition line. The arcs are the point. Source order is already a layout, chosen by whoever wrote the file, so a file's internal call structure can be drawn with no algorithm placing anything. Crabviz's idea, in the one place it is legible. Everything is arithmetic, not measurement. The Symbol view queries the laid-out DOM to place a callee row beside its line; a 6 820-line file cannot afford that. Here a line is exactly 20px at `10 + (n - 1) x 20`, so ~90 line elements exist at a time and the arcs, ports, rail rows and connectors are all functions of a line number. `src/mcp/tools.ts` scrolls at a 16.6ms median frame. - `GET /api/filecode/` — outline, one call group per (caller, callee) PAIR with its call-site lines, unresolved references, and the file's length. The source is NOT in it: it pages through `/api/source` 800 lines at a time with a discarded 150-line lead-in, so a page starting inside a block comment does not render prose as code, and so the ports and arcs are complete from the first frame while the text fills in behind them. - `intraFileCalls` is counted over the groups actually returned, so the header and the picture under it cannot disagree once a cap bites. - Above 40 arcs the diagram narrows to the symbol under the pointer (or the one the scroll position is inside) and the header states the total. Accent is for the pointer only, never for the filter. - Sticky outline rail at >= 1400px, following the reader down the file. - `QueryBuilder.getUnresolvedReferencesInFile` — one indexed lookup instead of one per symbol; `buildOutlineEntries` lifted out of `/api/file` so both readings of a file draw the same rows. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + README.md | 2 +- __tests__/ui-filecode-api.test.ts | 303 ++++++++ __tests__/ui-filecode-model.test.ts | 403 +++++++++++ docs/design/codegraph-ui-design-spec.md | 20 + site/src/content/docs/guides/viewer.md | 8 + src/db/queries.ts | 35 + src/index.ts | 11 + src/ui-server/api/file.ts | 82 ++- src/ui-server/api/filecode.ts | 297 ++++++++ src/ui-server/api/index.ts | 26 +- src/ui-server/api/source.ts | 54 ++ ui/README.md | 4 +- ui/src/App.svelte | 3 + ui/src/components/file/CodeArcs.svelte | 126 ++++ ui/src/components/file/FileCodeBlock.svelte | 273 ++++++++ ui/src/components/file/FileCodeOutline.svelte | 181 +++++ ui/src/components/file/FileCodeRail.svelte | 152 +++++ ui/src/components/file/FileModeTabs.svelte | 58 ++ ui/src/lib/api.ts | 56 ++ ui/src/lib/filecode-model.ts | 486 +++++++++++++ ui/src/lib/router.svelte.ts | 26 +- ui/src/views/FileCodeView.svelte | 645 ++++++++++++++++++ ui/src/views/FileView.svelte | 12 +- 24 files changed, 3227 insertions(+), 40 deletions(-) create mode 100644 __tests__/ui-filecode-api.test.ts create mode 100644 __tests__/ui-filecode-model.test.ts create mode 100644 src/ui-server/api/filecode.ts create mode 100644 ui/src/components/file/CodeArcs.svelte create mode 100644 ui/src/components/file/FileCodeBlock.svelte create mode 100644 ui/src/components/file/FileCodeOutline.svelte create mode 100644 ui/src/components/file/FileCodeRail.svelte create mode 100644 ui/src/components/file/FileModeTabs.svelte create mode 100644 ui/src/lib/filecode-model.ts create mode 100644 ui/src/views/FileCodeView.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca7b3f..52220f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you can't disagree. +- **Read a whole file, with its call graph in the margin, in `codegraph ui`.** The file screen gained a **Source** tab: the file itself, top to bottom, with the same gutter markers as the symbol view and the same right-hand list of what each line calls, positioned level with the line that calls it. A 6,800-line file scrolls at full speed — only the lines on screen are ever drawn, and the text pages in behind you while the markers are there from the first frame. + + In the left margin is an arc for every call that stays inside the file, drawn from the calling line to the line the callee is defined on. Nothing is laid out by an algorithm — the author already put the symbols in order, so source order does the work, and this is the one place a file's internal call structure is legible at a glance. Hover a line to light the arcs the function under your cursor takes part in, and click an arc to jump to the other end. On a file with more than forty of them the picture narrows to the symbol you're reading instead of drawing a wash of overlapping sweeps, with the total in the header. A rail on the far left lists the file's symbols and follows you as you scroll, when the window is wide enough for it. + ## [1.6.0] - 2026-08-26 diff --git a/README.md b/README.md index 69b322f..140304b 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ What you get on that screen: - **Blast radius** — direct dependents, everything within three hops, and how many files and test files that touches. - **Honest edges.** A guess CodeGraph isn't sure about is folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three hops says so. - **Search** (`/` or ⌘K) over every symbol and file, **entry points** to start from (routes, hubs, files that run code at import time), and a **trail** of the path you walked that lives in the URL, so you can send someone the exact route you took. -- Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. +- Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed. - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip. - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away. diff --git a/__tests__/ui-filecode-api.test.ts b/__tests__/ui-filecode-api.test.ts new file mode 100644 index 0000000..ee49348 --- /dev/null +++ b/__tests__/ui-filecode-api.test.ts @@ -0,0 +1,303 @@ +/** + * `GET /api/filecode` — everything the whole-file view draws (CG-52). + * + * Against a real indexed fixture over a real loopback server, like the rest of + * the viewer's API suite. The fixture is shaped around the four claims this + * endpoint makes that a hand-written payload could not prove: + * + * - a call group is one (CALLER, CALLEE) pair, not one per callee — the same + * helper reached from two functions has to come back as two rows, because a + * row is anchored to a line and there is no line that is both, + * - `intraFileCalls` counts exactly the arcs the viewer can draw from `calls`, + * so the header and the picture under it cannot disagree, + * - top-level code has an owner (the file node), which is the only way a + * statement outside every definition gets a port at all, + * - a reference that resolves to nothing still comes back, so a line calling a + * runtime builtin shows a hollow port instead of an empty gutter. + * + * The pure geometry is tested without a server in `ui-filecode-model.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs'; +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 { MAX_FILE_CALL_GROUPS, MAX_FILE_OUTSIDE_REFS } from '../src/ui-server/api/filecode'; + +let server: UiServerHandle; +let api: GraphApi; +let tempDir: string; +let projectRoot: string; + +function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port: server.port, + path: requestPath, + method: 'GET', + headers: { Host: `127.0.0.1:${server.port}` }, + setHost: false, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf-8'), + type: res.headers['content-type'], + }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +async function getCode(file: string, expected = 200): Promise { + const res = await request(`/api/filecode/${file}`); + expect(res.type).toBe('application/json; charset=utf-8'); + expect(res.status).toBe(expected); + return JSON.parse(res.body); +} + +function write(root: string, rel: string, body: string): void { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); +} + +/** Rows as `caller -> callee`, which is how the rail reads. */ +function pairs(payload: any): string[] { + const names = new Map( + payload.outline.items.map((e: any) => [e.id, e.name] as [string, string]) + ); + return payload.calls.items.map( + (c: any) => `${names.get(c.ownerId) ?? 'file'} -> ${c.relation.node.name}` + ); +} + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-filecode-')); + projectRoot = path.join(tempDir, 'project'); + + // `format` is called by TWO functions in this file and by one in another, and + // `render` calls it twice from two different lines — every grouping case in + // one file. + write( + projectRoot, + 'src/report.ts', + `import { widen } from './widen'; + +export function format(value: string): string { + return value.trim(); +} + +export function render(a: string, b: string): string { + const left = format(a); + const right = format(b); + return left + right; +} + +export function summarise(rows: string[]): string { + const head = format(rows[0] ?? ''); + console.log(head); + return widen(head); +} + +render('a', 'b'); +` + ); + write( + projectRoot, + 'src/widen.ts', + `export function widen(text: string): string { + return text + ' '; +} +` + ); + // Nothing in it reaches anything: the empty-rail, no-arc case. + write(projectRoot, 'src/quiet.ts', `export const NAME = 'quiet';\n`); + + const cg = CodeGraph.initSync(projectRoot, { + config: { include: ['src/**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + cg.resolveReferences(); + cg.close(); + + const viewerDir = path.join(tempDir, 'viewer'); + fs.mkdirSync(viewerDir, { recursive: true }); + fs.writeFileSync(path.join(viewerDir, 'index.html'), '
'); + + api = createGraphApi({ projectRoot }); + server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler }); +}, 120_000); + +afterAll(async () => { + api?.close(); + await server?.close(); + if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('GET /api/filecode', () => { + it('describes the file and its length, which is the view\'s layout', async () => { + const payload = await getCode('src/report.ts'); + expect(payload.file.path).toBe('src/report.ts'); + expect(payload.file.language).toBe('typescript'); + expect(payload.file.id).toBe('file:src/report.ts'); + expect(payload.drift).toBe(false); + // The count comes from disk, not from the index: it is the height of the + // scrolling document, and the source itself is paged in separately. + const onDisk = fs.readFileSync(path.join(projectRoot, 'src/report.ts'), 'utf-8'); + expect(payload.file.totalLines).toBe(onDisk.replace(/\n$/, '').split('\n').length); + }); + + it('returns the same outline rows the File view draws', async () => { + const code = await getCode('src/report.ts'); + const file = JSON.parse((await request('/api/file/src/report.ts')).body); + expect(code.outline.total).toBe(file.outline.total); + expect(code.outline.items.map((e: any) => e.name)).toEqual( + file.outline.items.map((e: any) => e.name) + ); + // A rail that disagreed with the source beside it would be worse than none. + for (const entry of code.outline.items) { + expect(entry.line).toBeGreaterThan(0); + expect(entry.endLine).toBeGreaterThanOrEqual(entry.line); + } + }); + + it('groups by the PAIR, so one callee reached from two functions is two rows', async () => { + const payload = await getCode('src/report.ts'); + const rows = pairs(payload); + expect(rows).toContain('render -> format'); + expect(rows).toContain('summarise -> format'); + + // …and the two lines `render` calls it from stay ONE row, with both lines. + const renderRow = payload.calls.items.find( + (c: any) => + c.relation.node.name === 'format' && + payload.outline.items.find((e: any) => e.id === c.ownerId)?.name === 'render' + ); + expect(renderRow.relation.lines.length).toBe(2); + expect(renderRow.relation.lines[0]).toBeLessThan(renderRow.relation.lines[1]); + }); + + it('rows are in call-site order — the only ordering the screen has', async () => { + const payload = await getCode('src/report.ts'); + const firstLines = payload.calls.items.map((c: any) => c.relation.lines[0] ?? Infinity); + const sorted = [...firstLines].sort((a: number, b: number) => a - b); + expect(firstLines).toEqual(sorted); + }); + + it('gives top-level code an owner, so a statement outside every definition has a port', async () => { + const payload = await getCode('src/report.ts'); + const topLevel = payload.calls.items.filter((c: any) => c.ownerId === payload.file.id); + // `render('a', 'b')` at the bottom of the file belongs to no symbol. + expect(topLevel.map((c: any) => c.relation.node.name)).toContain('render'); + }); + + it('counts exactly the arcs the payload can draw', async () => { + const payload = await getCode('src/report.ts'); + // Recompute the arc list the way the viewer does, from `calls` alone. + let arcs = 0; + for (const call of payload.calls.items) { + if (call.relation.node.file !== payload.file.path) continue; + for (const line of call.relation.lines) { + if (line !== call.relation.node.line) arcs++; + } + } + expect(payload.intraFileCalls).toBe(arcs); + // render x2, summarise x1, top-level render x1 — every call that stays home. + expect(payload.intraFileCalls).toBeGreaterThanOrEqual(4); + }); + + it('does not count a cross-file call as an arc', async () => { + const payload = await getCode('src/report.ts'); + const widen = payload.calls.items.find((c: any) => c.relation.node.name === 'widen'); + expect(widen).toBeDefined(); + expect(widen.relation.node.file).toBe('src/widen.ts'); + }); + + it('returns references that resolved to nothing, with a line and a plain name', async () => { + const payload = await getCode('src/report.ts'); + const names = payload.outside.items.map((r: any) => r.name); + // `console.log` reaches a runtime builtin; the gutter must still show it. + expect(names).toContain('log'); + for (const ref of payload.outside.items) { + expect(ref.line).toBeGreaterThan(0); + expect(ref.name).toMatch(/^[A-Za-z_$][\w$]*$/); + } + expect(payload.outside.total).toBe(payload.outside.items.length); + expect(payload.outside.shown).toBeLessThanOrEqual(MAX_FILE_OUTSIDE_REFS); + }); + + it('answers for a file that reaches nothing without inventing rows', async () => { + const payload = await getCode('src/quiet.ts'); + expect(payload.calls.total).toBe(0); + expect(payload.calls.items).toEqual([]); + expect(payload.intraFileCalls).toBe(0); + expect(payload.file.totalLines).toBe(1); + }); + + it('every capped list still reports its real total', async () => { + const payload = await getCode('src/report.ts'); + for (const list of [payload.outline, payload.calls, payload.outside]) { + expect(list.shown).toBe(list.items.length); + expect(list.total).toBeGreaterThanOrEqual(list.shown); + expect(list.truncated).toBe(list.shown < list.total); + } + expect(payload.calls.shown).toBeLessThanOrEqual(MAX_FILE_CALL_GROUPS); + }); + + it('refuses a path outside the project before it looks in the index', async () => { + // The chokepoint answers "outside the project", not "not indexed" — the + // order is what makes that true by construction. See `resolveRequestedFile`. + const res = await request('/api/filecode//etc/passwd'); + expect(res.status).toBe(403); + expect(JSON.parse(res.body).code).toBe('refused'); + }); + + it('answers 404 for a file that is fine but not indexed', async () => { + const payload = await getCode('src/nope.ts', 404); + expect(payload.code).toBe('not-found'); + expect(payload.error).toMatch(/not in this CodeGraph index/); + }); + + it('says what the endpoint wants when given no path', async () => { + const res = await request('/api/filecode'); + expect(res.status).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/\/api\/filecode\//); + }); + + it('is listed on the API index', async () => { + const body = JSON.parse((await request('/api')).body); + expect(body.endpoints.find((e: any) => e.path === '/api/filecode/')).toBeDefined(); + // The shorter route must still resolve to the File view's own endpoint. + expect(body.endpoints.find((e: any) => e.path === '/api/file/')).toBeDefined(); + }); +}); + +describe('drift', () => { + it('flags a file that changed on disk and withholds its length', async () => { + const file = path.join(projectRoot, 'src/widen.ts'); + const original = fs.readFileSync(file, 'utf-8'); + try { + fs.writeFileSync(file, `// a new first line\n${original}`); + const payload = await getCode('src/widen.ts'); + expect(payload.drift).toBe(true); + expect(payload.reason).toMatch(/changed on disk/); + // The rows are still true about the graph; only the line numbers are not, + // which is exactly why the view draws the banner instead of the source. + expect(payload.outline.total).toBeGreaterThan(0); + } finally { + fs.writeFileSync(file, original); + } + }); +}); diff --git a/__tests__/ui-filecode-model.test.ts b/__tests__/ui-filecode-model.test.ts new file mode 100644 index 0000000..f2dcd5d --- /dev/null +++ b/__tests__/ui-filecode-model.test.ts @@ -0,0 +1,403 @@ +/** + * The whole-file view's geometry (CG-52), tested without a browser. + * + * Everything on that screen — where a line sits, which lines are rendered, + * which page has to be fetched, where a rail row lands, what an arc's path is — + * is arithmetic over line numbers, and that is deliberate: measuring six + * thousand laid-out lines is neither 60 fps nor possible. So the arithmetic is + * the thing worth pinning, and it can be pinned here. + * + * The API side is `ui-filecode-api.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + ARC_COLUMN, + ARC_CROWD_LIMIT, + CODE_LINE_HEIGHT, + CODE_TOP_PAD, + PAGE_LEAD_IN, + PAGE_LINES, + ROW_HEIGHT, + arcPath, + arcSummary, + arcsInRange, + buildFileArcs, + buildFileCallRows, + buildFileRefs, + documentHeight, + lineAtOffset, + lineCentre, + lineTop, + ownerAt, + pageFor, + pageOf, + pagesForRange, + railHeight, + rowsInRange, + visibleArcs, + visibleLines, +} from '../ui/src/lib/filecode-model'; +import type { + WireFileCall, + WireFileCodePayload, + WireNodeRef, + WireOutlineEntry, + WireRelation, +} from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function node(over: Partial & { id: string; name: string }): WireNodeRef { + return { + kind: 'function', + qualifiedName: over.name, + file: 'src/a.ts', + line: 1, + endLine: 1, + language: 'typescript', + test: false, + ...over, + } as WireNodeRef; +} + +function relation(target: WireNodeRef, lines: number[], over: Partial = {}): WireRelation { + return { + node: target, + edgeKinds: ['calls'], + edges: lines.map((line) => ({ kind: 'calls', line, col: 4 })), + edgeCount: lines.length, + lines, + confidence: null, + uncertain: false, + synthesized: false, + ...over, + } as WireRelation; +} + +function call(ownerId: string, ownerLine: number, rel: WireRelation): WireFileCall { + return { ownerId, ownerLine, relation: rel }; +} + +function entry(over: Partial & { id: string; name: string }): WireOutlineEntry { + return { + kind: 'function', + qualifiedName: over.name, + file: 'src/a.ts', + line: 1, + endLine: 1, + language: 'typescript', + test: false, + parentId: null, + depth: 0, + fanIn: 0, + fanOut: 0, + ...over, + } as WireOutlineEntry; +} + +function payloadWith(calls: WireFileCall[], outline: WireOutlineEntry[] = []): WireFileCodePayload { + return { + file: { + path: 'src/a.ts', + language: 'typescript', + size: 100, + indexedAt: 0, + contentHash: 'h', + generated: false, + test: false, + errors: [], + id: 'file:src/a.ts', + totalLines: 500, + }, + drift: false, + outline: { total: outline.length, shown: outline.length, truncated: false, items: outline }, + calls: { total: calls.length, shown: calls.length, truncated: false, items: calls }, + outside: { total: 0, shown: 0, truncated: false, items: [] }, + intraFileCalls: 0, + timing: { elapsedMs: 0 }, + }; +} + +/* ---------------------------------------------------------------- pixels -- */ + +describe('line arithmetic', () => { + it('places line 1 at the top pad and every line a fixed step below', () => { + expect(lineTop(1)).toBe(CODE_TOP_PAD); + expect(lineTop(2)).toBe(CODE_TOP_PAD + CODE_LINE_HEIGHT); + expect(lineCentre(1)).toBe(CODE_TOP_PAD + CODE_LINE_HEIGHT / 2); + }); + + it('round-trips an offset back to its line', () => { + for (const line of [1, 2, 17, 400, 6820]) { + expect(lineAtOffset(lineTop(line), 6820)).toBe(line); + expect(lineAtOffset(lineCentre(line), 6820)).toBe(line); + } + // The pads above and below read as the line they are adjacent to. + expect(lineAtOffset(0, 100)).toBe(1); + expect(lineAtOffset(999_999, 100)).toBe(100); + }); + + it('sizes the document from the line count alone', () => { + expect(documentHeight(6820)).toBe(CODE_TOP_PAD + 6820 * CODE_LINE_HEIGHT + 120); + expect(documentHeight(0)).toBe(CODE_TOP_PAD + 120); + }); +}); + +describe('visibleLines', () => { + it('renders a viewport plus overscan, never the whole file', () => { + const { first, last } = visibleLines(60_000, 900, 6820); + expect(first).toBeLessThan(lineAtOffset(60_000, 6820)); + expect(last - first).toBeLessThan(150); + // The viewport itself is covered. + expect(first).toBeLessThanOrEqual(lineAtOffset(60_000, 6820)); + expect(last).toBeGreaterThanOrEqual(lineAtOffset(60_900, 6820)); + }); + + it('clamps at both ends', () => { + expect(visibleLines(0, 900, 6820).first).toBe(1); + expect(visibleLines(10_000_000, 900, 6820).last).toBe(6820); + expect(visibleLines(0, 900, 0)).toEqual({ first: 1, last: 0 }); + }); +}); + +describe('paging', () => { + it('asks for a lead-in it then throws away', () => { + const page = pageFor(3, 6820); + expect(page.from).toBe(3 * PAGE_LINES + 1); + expect(page.to).toBe(4 * PAGE_LINES); + expect(page.requestFrom).toBe(page.from - PAGE_LEAD_IN); + }); + + it('never reaches before line 1, and never past the end', () => { + expect(pageFor(0, 6820).requestFrom).toBe(1); + expect(pageFor(8, 6820).to).toBe(6820); + }); + + it('stays inside the source endpoint\'s per-request line cap', () => { + // MAX_SOURCE_LINES is 4000; a page plus its lead-in must fit, or the last + // lines of a page would silently arrive truncated. + const page = pageFor(5, 100_000); + expect(page.to - page.requestFrom + 1).toBeLessThanOrEqual(4000); + }); + + it('names every page a rendered range touches', () => { + expect(pagesForRange(1, 40, 6820)).toEqual([0]); + expect(pagesForRange(PAGE_LINES - 2, PAGE_LINES + 2, 6820)).toEqual([0, 1]); + expect(pagesForRange(1, 0, 0)).toEqual([]); + expect(pageOf(1)).toBe(0); + expect(pageOf(PAGE_LINES)).toBe(0); + expect(pageOf(PAGE_LINES + 1)).toBe(1); + }); +}); + +/* ------------------------------------------------------------- ownership -- */ + +describe('ownerAt', () => { + const outline = [ + entry({ id: 'class', name: 'Service', kind: 'class', line: 10, endLine: 90 }), + entry({ id: 'm1', name: 'run', kind: 'method', line: 20, endLine: 40, depth: 1 }), + entry({ id: 'm2', name: 'stop', kind: 'method', line: 50, endLine: 60, depth: 1 }), + ]; + + it('answers with the DEEPEST symbol holding the line', () => { + // Not the class: it holds every line equally, so hovering anywhere inside + // it would light every arc in it. + expect(ownerAt(outline, 25)).toBe('m1'); + expect(ownerAt(outline, 55)).toBe('m2'); + expect(ownerAt(outline, 45)).toBe('class'); + }); + + it('answers null outside every symbol', () => { + expect(ownerAt(outline, 5)).toBeNull(); + expect(ownerAt(outline, 200)).toBeNull(); + }); +}); + +/* ---------------------------------------------------------------- ports -- */ + +describe('buildFileRefs', () => { + it('marks every recorded call site with its column', () => { + const target = node({ id: 't', name: 'format', line: 3 }); + const refs = buildFileRefs(payloadWith([call('o', 1, relation(target, [8, 9]))])); + expect([...refs.keys()].sort((a, b) => a - b)).toEqual([8, 9]); + expect(refs.get(8)![0]).toMatchObject({ ident: 'format', col: 4, targetId: 't', outside: false }); + }); + + it('still marks a call site the capped edge list left out', () => { + // A relation caps its EDGES but never its `lines`; without the fallback the + // overflow call sites would silently lose their ports. + const target = node({ id: 't', name: 'format', line: 3 }); + const rel = relation(target, [8, 9, 10]); + rel.edges = rel.edges.slice(0, 1); + const refs = buildFileRefs(payloadWith([call('o', 1, rel)])); + expect(refs.get(10)).toHaveLength(1); + expect(refs.get(10)![0]!.col).toBeNull(); + }); + + it('carries unresolved references, which have no destination', () => { + const payload = payloadWith([]); + payload.outside = { + total: 1, + shown: 1, + truncated: false, + items: [{ line: 12, col: 6, name: 'log', kind: 'calls' }], + }; + const ref = buildFileRefs(payload).get(12)![0]!; + expect(ref).toMatchObject({ ident: 'log', targetId: null, outside: true }); + }); +}); + +/* ----------------------------------------------------------------- rail -- */ + +describe('buildFileCallRows', () => { + it('puts a row at the centre of its first call site', () => { + const rows = buildFileCallRows( + payloadWith([call('o', 1, relation(node({ id: 't', name: 'format' }), [100]))]) + ); + expect(rows[0]!.top).toBe(lineCentre(100) - ROW_HEIGHT / 2); + }); + + it('pushes rows apart rather than letting them overlap, keeping source order', () => { + const rows = buildFileCallRows( + payloadWith([ + call('o', 1, relation(node({ id: 'a', name: 'a' }), [10])), + call('o', 1, relation(node({ id: 'b', name: 'b' }), [11])), + call('o', 1, relation(node({ id: 'c', name: 'c' }), [12])), + ]) + ); + expect(rows.map((r) => r.call.relation.node.name)).toEqual(['a', 'b', 'c']); + for (let i = 1; i < rows.length; i++) { + expect(rows[i]!.top - rows[i - 1]!.top).toBeGreaterThanOrEqual(ROW_HEIGHT); + } + // The first one still gets exactly the place it wanted. + expect(rows[0]!.top).toBe(lineCentre(10) - ROW_HEIGHT / 2); + }); + + it('keys a row by the PAIR, so one callee from two callers is two rows', () => { + const target = node({ id: 't', name: 'format' }); + const rows = buildFileCallRows( + payloadWith([ + call('render', 5, relation(target, [8])), + call('summarise', 20, relation(target, [22])), + ]) + ); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((r) => r.key)).size).toBe(2); + }); + + it('sends a row with no recorded call site to the end, where a cap trims it', () => { + const rows = buildFileCallRows( + payloadWith([ + call('o', 1, relation(node({ id: 'nolines', name: 'z' }), [])), + call('o', 1, relation(node({ id: 'lined', name: 'a' }), [400])), + ]) + ); + expect(rows.map((r) => r.call.relation.node.id)).toEqual(['lined', 'nolines']); + }); + + it('windows by pixel range and reports the height it needs', () => { + const rows = buildFileCallRows( + payloadWith( + [10, 200, 4000].map((line, i) => + call('o', 1, relation(node({ id: `t${i}`, name: `t${i}` }), [line])) + ) + ) + ); + expect(rowsInRange(rows, 0, 600).map((r) => r.call.relation.node.id)).toEqual(['t0']); + expect(rowsInRange(rows, 3900, 4100).map((r) => r.call.relation.node.id)).toEqual(['t1']); + // A stretch of file with no calls in it draws no rows at all. + expect(rowsInRange(rows, 5000, 10_000)).toEqual([]); + expect(railHeight(rows)).toBeGreaterThan(lineCentre(4000)); + expect(railHeight([])).toBe(0); + }); +}); + +/* ----------------------------------------------------------------- arcs -- */ + +describe('buildFileArcs', () => { + const local = (id: string, name: string, line: number): WireNodeRef => + node({ id, name, line, endLine: line + 5, file: 'src/a.ts' }); + + it('draws one arc per call site whose callee is defined in the same file', () => { + const payload = payloadWith([ + call('r', 30, relation(local('fmt', 'format', 3), [31, 32])), + call('r', 30, relation(node({ id: 'far', name: 'widen', file: 'src/b.ts', line: 1 }), [33])), + ]); + const arcs = buildFileArcs(payload, buildFileCallRows(payload)); + expect(arcs).toHaveLength(2); + expect(arcs.map((a) => a.fromLine).sort()).toEqual([31, 32]); + expect(arcs.every((a) => a.toLine === 3)).toBe(true); + }); + + it('skips a call sitting on its own callee\'s definition line', () => { + const payload = payloadWith([call('r', 10, relation(local('r', 'recurse', 10), [10, 14]))]); + const arcs = buildFileArcs(payload, buildFileCallRows(payload)); + expect(arcs.map((a) => a.fromLine)).toEqual([14]); + }); + + it('sits short arcs innermost, by their own span rather than by rank', () => { + const payload = payloadWith([ + call('r', 100, relation(local('near', 'near', 98), [100])), + call('r', 100, relation(local('far', 'far', 2), [101])), + ]); + const arcs = buildFileArcs(payload, buildFileCallRows(payload)); + const depth = (key: string): number => + Number(/A([\d.]+),/.exec(arcs.find((a) => a.targetId === key)!.d)![1]); + expect(depth('near')).toBeLessThan(depth('far')); + expect(depth('near')).toBeGreaterThan(0); + expect(depth('far')).toBeLessThanOrEqual(ARC_COLUMN); + + // Filtering to one symbol must not move the survivors sideways, which is + // exactly what a rank-based depth would do. + const filtered = buildFileArcs( + payloadWith([call('r', 100, relation(local('near', 'near', 98), [100]))]), + buildFileCallRows(payloadWith([call('r', 100, relation(local('near', 'near', 98), [100]))])) + ); + expect(Number(/A([\d.]+),/.exec(filtered[0]!.d)![1])).toBeGreaterThan(0); + }); + + it('bulges LEFT in both directions', () => { + // Both ends sit on the column's right edge; the sweep flag is what keeps a + // downward arc and an upward one on the same side of the gutter. + expect(arcPath(10, 40, 30)).toMatch(/^M56,\d+(\.\d+)? A30\.0,\d+(\.\d+)? 0 0 0 56,/); + expect(arcPath(40, 10, 30)).toMatch(/ 0 0 1 56,/); + }); +}); + +describe('visibleArcs', () => { + const arcs = [ + { key: 'a', ownerId: 'x', targetId: 'y', minLine: 1, maxLine: 10 }, + { key: 'b', ownerId: 'z', targetId: 'w', minLine: 50, maxLine: 60 }, + ] as any[]; + + it('shows everything while there are few enough to read', () => { + expect(visibleArcs(arcs, null, false)).toHaveLength(2); + }); + + it('shows only the focused symbol\'s once the file is crowded — both directions', () => { + expect(visibleArcs(arcs, 'x', true).map((a) => a.key)).toEqual(['a']); + // A reader hovering a symbol is asking about its neighbourhood, so the + // calls INTO it count too. + expect(visibleArcs(arcs, 'y', true).map((a) => a.key)).toEqual(['a']); + expect(visibleArcs(arcs, null, true)).toEqual([]); + }); + + it('windows by line range', () => { + expect(arcsInRange(arcs, 1, 20).map((a) => a.key)).toEqual(['a']); + expect(arcsInRange(arcs, 5, 55).map((a) => a.key)).toEqual(['a', 'b']); + expect(arcsInRange(arcs, 20, 40)).toEqual([]); + }); + + it('the crowd limit is the spec\'s', () => { + expect(ARC_CROWD_LIMIT).toBe(40); + }); +}); + +describe('arcSummary', () => { + it('says nothing rather than "0 calls"', () => { + expect(arcSummary(0)).toMatch(/No calls/); + expect(arcSummary(1)).toBe('1 call stays within this file'); + expect(arcSummary(209)).toBe('209 calls stay within this file'); + }); +}); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 71ab2a3..ce024d6 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -159,6 +159,26 @@ windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); live together in `ui/src/lib/file-model.ts`. Keyboard: ↑/↓ within a pane, ←/→ across the three panes, Enter follows; `?hl=` selects the DEEPEST outline row whose range holds the line. +**Whole-file source, as built (phase 2, CG-52).** `?src=1` on the same route. Four columns inside +one scroller: sticky outline rail (240px, only at ≥ 1400px) | arcs 56px | source | callee rail 320px. +The line grid, the 6x6 ports and the accent call-site links are the Symbol view's, unchanged — what +differs is that **line positions are arithmetic, not measured**: every line is exactly 20px and sits +at `10 + (n - 1) x 20`, so a 6 820-line file renders ~90 line elements and the arcs, ports, rail +rows and connectors are all functions of a line number. `ui/src/lib/filecode-model.ts` holds the +constant; `FileCodeBlock.svelte`'s CSS holds the other half of it, and they must move together. +Source pages in 800 lines at a time from `/api/source`, each request reaching back 150 lines that are +then discarded so a page starting inside a block comment does not render prose as code; a line whose +page has not arrived still shows its number, its port and its place. Callee-rail rows are one per +(CALLING symbol, called symbol) PAIR rather than one per callee — a row is anchored to a line and a +helper called from two functions a thousand lines apart has no line that is both — and uncertain rows +stay in place with their dotted underline rather than folding, because a fold has nowhere to sit on +this screen. Arcs are half-ellipses bulging left, both ends on the arc column's right edge, depth a +log function of the arc's own SPAN (so short arcs sit innermost and filtering never moves a survivor +sideways); `--ink-4` 1px at rest, `--accent` 1.5px when the call line or the callee is under the +pointer — never as a consequence of the crowding filter. Above 40 arcs only the focused symbol's are +drawn (hovered symbol, else the symbol the scroll position is inside) and the header states the +total. Clicking an arc scrolls to the callee's definition and marks it. Data: `GET /api/filecode/`. + ### 3.5 Flow strip (`#/flow/`) Header: "Flow" + a `