From 58dad12f89ca17eb41a55e8fcccdd3d7510c92aa Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 27 Aug 2026 01:43:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20the=20File=20view=20=E2=80=94=20out?= =?UTF-8?q?line=20in=20source=20order=20between=20two=20dependency=20rails?= =?UTF-8?q?=20(CG-46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a file path now opens the file itself: what reaches into it, its symbols in source order, and what it reaches. The two rails count DEPENDENCIES, not import statements. The prototype drew `imports` edges; on this repo `src/graph/traversal.ts` imports two files and depends on four, because it reaches the LRU cache through a call no import names. A rail headed "Imports 2" would be quietly wrong about what changing the file would touch, which is the only question the screen answers — so the rails read `getFileDependencies` / `getFileDependents` and merge the import rows in for the symbol names. Imports that resolved to nothing indexed keep their own section rather than vanishing. The outline is windowed above 250 rows against a fixed 28px row: this repo's own fixtures hold a 1,681-symbol `.d.ts`, and paging it would hide the one thing an outline is for. `src/mcp/tools.ts` draws its 135 rows whole. `/api/file` gains `topLevel.calls` — module-level calls out of the file node — so a file that RUNS something offers the badge that opens it as a symbol, the only place code belonging to no symbol can be read. File results in the search palette and the entry-point list now land here rather than on the file node's Symbol view. Co-Authored-By: Claude Opus 5 --- __tests__/ui-file-model.test.ts | 304 ++++++++++++++++ __tests__/ui-server-api.test.ts | 11 + docs/design/codegraph-ui-design-spec.md | 16 + src/ui-server/api/file.ts | 18 + ui/src/components/TopBar.svelte | 9 +- ui/src/components/file/FileOutline.svelte | 224 ++++++++++++ ui/src/components/file/FileRail.svelte | 209 +++++++++++ ui/src/lib/api.ts | 58 +++ ui/src/lib/file-model.ts | 200 +++++++++++ ui/src/views/FileView.svelte | 409 +++++++++++++++++++++- ui/src/views/HomeView.svelte | 7 + 11 files changed, 1454 insertions(+), 11 deletions(-) create mode 100644 __tests__/ui-file-model.test.ts create mode 100644 ui/src/components/file/FileOutline.svelte create mode 100644 ui/src/components/file/FileRail.svelte create mode 100644 ui/src/lib/file-model.ts diff --git a/__tests__/ui-file-model.test.ts b/__tests__/ui-file-model.test.ts new file mode 100644 index 0000000..893d741 --- /dev/null +++ b/__tests__/ui-file-model.test.ts @@ -0,0 +1,304 @@ +/** + * The File view's models, without a browser (CG-46). + * + * The decision under test throughout is the rails' source of truth: they are + * built from `dependencies` / `dependents` — the engine's own + * `getFileDependencies` / `getFileDependents` — and merely *decorated* with + * the `imports` rows. Getting that backwards is not a cosmetic bug: it silently + * understates what a change to the file would reach, which is the only reason + * the screen exists. + * + * The geometry-free sibling of `ui-symbol-model.test.ts` and + * `ui-search-model.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildFileOutline, + buildFileRail, + fileMetaLine, + formatBytes, + looksLikeTest, + OUTLINE_ROW_HEIGHT, + OUTLINE_VIRTUAL_THRESHOLD, +} from '../ui/src/lib/file-model'; +import type { WireFilePayload, WireImportRow, WireOutlineEntry } from '../ui/src/lib/api'; + +/* ------------------------------------------------------------- fixtures -- */ + +function importRow(over: Partial = {}): WireImportRow { + const symbols = over.symbols ?? [ + { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 }, + ]; + return { + file: over.file ?? 'src/db/queries.ts', + test: over.test ?? false, + symbols, + symbolCount: over.symbolCount ?? symbols.length, + }; +} + +function entry(over: Partial = {}): WireOutlineEntry { + return { + id: over.id ?? 'method:x', + kind: 'method', + name: 'traverseBFS', + qualifiedName: 'GraphTraverser.traverseBFS', + file: 'src/graph/traversal.ts', + line: 48, + endLine: 150, + language: 'typescript', + test: false, + parentId: 'class:GraphTraverser', + depth: 1, + fanIn: 3, + fanOut: 7, + ...over, + } as WireOutlineEntry; +} + +function payload(over: Partial = {}): WireFilePayload { + return { + file: { + path: 'src/graph/traversal.ts', + language: 'typescript', + size: 24216, + modifiedAt: 1, + indexedAt: 2, + contentHash: 'abc', + nodeCount: 26, + generated: false, + test: false, + errors: [], + id: 'file:src/graph/traversal.ts', + }, + topLevel: { calls: 0 }, + drift: false, + outline: { total: 0, shown: 0, truncated: false, items: [] }, + imports: { total: 0, shown: 0, truncated: false, items: [] }, + importedBy: { total: 0, shown: 0, truncated: false, items: [] }, + unresolvedImports: [], + dependencies: [], + dependents: [], + ...over, + } as WireFilePayload; +} + +/* ----------------------------------------------------------------- rail -- */ + +describe('the import rails', () => { + it('counts every dependency, not just the ones an import statement named', () => { + // The real shape on this repo: traversal.ts imports two files and depends + // on four — it reaches the LRU cache through a call with no import. + const rail = buildFileRail( + [ + 'src/db/queries.ts', + 'src/resolution/lru-cache.ts', + 'src/types.ts', + 'scripts/agent-eval/probe.mjs', + ], + [importRow({ file: 'src/db/queries.ts' }), importRow({ file: 'src/types.ts' })] + ); + + expect(rail.total).toBe(4); + expect(rail.rows).toHaveLength(4); + expect(rail.rows.filter((r) => r.imported).map((r) => r.path)).toEqual([ + 'src/db/queries.ts', + 'src/types.ts', + ]); + expect(rail.rows.find((r) => r.path === 'src/resolution/lru-cache.ts')?.imported).toBe(false); + }); + + it('names the symbols an import row carries, on the row for that file', () => { + const rail = buildFileRail( + ['src/db/queries.ts'], + [ + importRow({ + symbols: [ + { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 }, + { id: 'iface:R', name: 'Row', kind: 'interface', line: 12 }, + ], + }), + ] + ); + expect(rail.rows[0]?.symbols.map((s) => s.name)).toEqual(['QueryBuilder', 'Row']); + expect(rail.rows[0]?.symbolCount).toBe(2); + }); + + it('does not count a file node as a named symbol', () => { + // An `importedBy` edge's far end is the importing file's own file node, so + // its "symbols" repeat the path already in the row. A `1` there would be a + // count of nothing. + const rail = buildFileRail( + ['src/index.ts'], + [ + importRow({ + file: 'src/index.ts', + symbols: [{ id: 'file:src/index.ts', name: 'index.ts', kind: 'file', line: 1 }], + }), + ] + ); + expect(rail.rows[0]?.symbolCount).toBe(0); + expect(rail.rows[0]?.imported).toBe(true); + }); + + it('sorts production files before tests, each alphabetically', () => { + const rail = buildFileRail( + ['src/z.ts', '__tests__/graph.test.ts', 'src/a.ts', '__tests__/a.test.ts'], + [] + ); + expect(rail.rows.map((r) => r.path)).toEqual([ + 'src/a.ts', + 'src/z.ts', + '__tests__/a.test.ts', + '__tests__/graph.test.ts', + ]); + expect(rail.testCount).toBe(2); + }); + + it('trusts the server about what is a test, and falls back to the path', () => { + const rail = buildFileRail( + ['src/looks-normal.ts', 'src/other.ts'], + // The server can see more than a path; a row it marks wins. + [importRow({ file: 'src/looks-normal.ts', test: true })] + ); + expect(rail.rows[0]?.path).toBe('src/other.ts'); + expect(rail.rows[1]?.test).toBe(true); + }); + + it('de-duplicates a file the engine listed twice', () => { + const rail = buildFileRail(['src/a.ts', 'src/a.ts'], []); + expect(rail.rows).toHaveLength(1); + expect(rail.total).toBe(1); + }); + + it('folds unresolved imports by name, keeping every line', () => { + const rail = buildFileRail( + [], + [], + [ + { name: 'node:fs', line: 12 }, + { name: 'react', line: 3 }, + { name: 'node:fs', line: 4 }, + ] + ); + expect(rail.outside).toEqual([ + { name: 'node:fs', lines: [4, 12] }, + { name: 'react', lines: [3] }, + ]); + // Outside-index rows never inflate the dependency count. + expect(rail.total).toBe(0); + }); +}); + +describe('looksLikeTest', () => { + it('recognises the shapes an unnamed dependency can arrive in', () => { + expect(looksLikeTest('__tests__/graph.test.ts')).toBe(true); + expect(looksLikeTest('src/service.spec.ts')).toBe(true); + expect(looksLikeTest('test/helper.go')).toBe(true); + expect(looksLikeTest('__tests__/fixtures/app/main.ts')).toBe(true); + }); + + it('errs towards production — misfiling a real file is the worse mistake', () => { + expect(looksLikeTest('src/latest.ts')).toBe(false); + expect(looksLikeTest('src/protest/index.ts')).toBe(false); + expect(looksLikeTest('src/testing-library.ts')).toBe(false); + }); +}); + +/* -------------------------------------------------------------- outline -- */ + +describe('the file outline', () => { + it('keeps the server order and indents by depth', () => { + const rows = buildFileOutline( + payload({ + outline: { + total: 3, + shown: 3, + truncated: false, + items: [ + entry({ id: 'class:C', kind: 'class', name: 'GraphTraverser', depth: 0, line: 34 }), + entry({ id: 'method:m', depth: 1, line: 48 }), + entry({ id: 'prop:p', kind: 'property', name: 'queries', depth: 1, line: 35 }), + ], + }, + }) + ); + expect(rows.map((r) => r.entry.id)).toEqual(['class:C', 'method:m', 'prop:p']); + expect(rows.map((r) => r.indent)).toEqual([0, 1, 1]); + }); + + it('dims data rather than behaviour', () => { + const rows = buildFileOutline( + payload({ + outline: { + total: 4, + shown: 4, + truncated: false, + items: [ + entry({ id: 'a', kind: 'property' }), + entry({ id: 'b', kind: 'enum_member' }), + entry({ id: 'c', kind: 'method' }), + entry({ id: 'd', kind: 'class' }), + ], + }, + }) + ); + expect(rows.map((r) => r.dimmed)).toEqual([true, true, false, false]); + }); + + it('clamps the indent so a deeply nested closure stays in its column', () => { + const rows = buildFileOutline( + payload({ + outline: { + total: 1, + shown: 1, + truncated: false, + items: [entry({ depth: 9 })], + }, + }) + ); + expect(rows[0]?.indent).toBe(3); + }); + + it('windows past a threshold that leaves ordinary files alone', () => { + // 135 symbols in this repo's biggest hand-written file (src/mcp/tools.ts); + // 1,681 in the generated fixture that motivated the window. + expect(OUTLINE_VIRTUAL_THRESHOLD).toBeGreaterThan(135); + expect(OUTLINE_ROW_HEIGHT).toBeGreaterThan(0); + }); +}); + +/* --------------------------------------------------------------- header -- */ + +describe('the header line', () => { + it('counts the outline, not the file record', () => { + // nodeCount includes the file node and its import declarations; neither is + // a row, and a header disagreeing with the list under it is unresolvable. + const line = fileMetaLine( + payload({ + file: { ...payload().file, nodeCount: 26 }, + outline: { total: 23, shown: 23, truncated: false, items: [] }, + }) + ); + expect(line).toBe('typescript · 23.6 KB · 23 symbols'); + }); + + it('tags a generated file and a test file', () => { + const line = fileMetaLine( + payload({ + file: { ...payload().file, generated: true, test: true, size: 1024 }, + outline: { total: 1, shown: 1, truncated: false, items: [] }, + }) + ); + expect(line).toBe('typescript · 1.0 KB · 1 symbol · generated · test'); + }); + + it('formats sizes for scale, never for accounting', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(999)).toBe('999 B'); + expect(formatBytes(24216)).toBe('23.6 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + expect(formatBytes(Number.NaN)).toBe('—'); + }); +}); diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts index 156d7c3..dedf8af 100644 --- a/__tests__/ui-server-api.test.ts +++ b/__tests__/ui-server-api.test.ts @@ -845,6 +845,17 @@ describe('GET /api/file/', () => { expect(body.dependencies).toContain('src/types.ts'); }); + it('says whether the file runs anything at its top level', async () => { + // `src/main.ts` instantiates a Service and calls two functions outside + // every definition — code no outline row can show, because it belongs to + // no symbol. `src/cache.ts` only defines things. + const main = await getJson('/api/file/src/main.ts'); + expect(main.topLevel.calls).toBeGreaterThanOrEqual(2); + + const cache = await getJson('/api/file/src/cache.ts'); + expect(cache.topLevel.calls).toBe(0); + }); + it('404s a file that is not in the index and refuses one outside the project', async () => { const missing = await getStatusAndJson('/api/file/src/nope.ts'); expect(missing.status).toBe(404); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 9dfada1..71ab2a3 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -143,6 +143,22 @@ Grid **300px | minmax(480px,1fr) | 300px**: Imported by · outline (source order File rows 12px mono, 5px 14px padding, `--rule-faint` separators; files outside the index in `--ink-3`, not clickable. Header: file glyph, basename as h1, `lang · KB · N symbols · generated`, full path. +**As built (phase 1, CG-46).** The two rails count **dependencies**, not import statements — +`getFileDependencies` / `getFileDependents`, every cross-file edge except `contains`. The prototype +drew `imports` edges alone, and on this repo that understates the answer: `src/graph/traversal.ts` +imports two files and depends on four (it reaches `src/resolution/lru-cache.ts` through a call no +import names). The import rows are still merged in — they carry the symbol NAMES, shown as a count +on the row and in full in its tooltip. Rows sort production-first then alphabetically, tests last. +Imports that resolved to nothing indexed are listed under **Outside the index**, in `--ink-3` and +not clickable, so a file importing `react` and `fs` does not read as having one dependency. +The header's `N symbols` is the OUTLINE's total, not the file record's node count (which includes +the file node and its import declarations). A file that runs code at its top level — an edge out of +the file node — carries a badge ("Runs N calls at the top level — see what it calls") that focuses +the file node, the only place that code can be read. Outline rows are a fixed 28px and the list is +windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); the two constants +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. + ### 3.5 Flow strip (`#/flow/`) Header: "Flow" + a `