Parcourir la source

feat(ui): the whole file — full source with gutter ports and intra-file call arcs (CG-52)

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/<path>` — 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 <noreply@anthropic.com>
Colby McHenry il y a 1 semaine
Parent
commit
bd99c5e99a

+ 4 - 0
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
 

+ 1 - 1
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.
 

+ 303 - 0
__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<any> {
+  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<string, string>(
+    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'), '<!doctype html><div id="app"></div>');
+
+  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\/<path>/);
+  });
+
+  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/<path>')).toBeDefined();
+    // The shorter route must still resolve to the File view's own endpoint.
+    expect(body.endpoints.find((e: any) => e.path === '/api/file/<path>')).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);
+    }
+  });
+});

+ 403 - 0
__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<WireNodeRef> & { 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> = {}): 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<WireOutlineEntry> & { 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');
+  });
+});

+ 20 - 0
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=<line>` 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/<path>`.
+
 ### 3.5 Flow strip (`#/flow/<key>`)
 Header: "Flow" + a `<select>` of flows (`--paper-2`, `--rule-soft` border, 12.5px sans) + a 78ch note.
 Cards **380px** wide, `--rule-soft` border (`--ink` on hover, `--accent` when current), header grid `16px | 1fr` padding `10px 12px 6px`

+ 8 - 0
site/src/content/docs/guides/viewer.md

@@ -42,6 +42,14 @@ The viewer never presents a guess as a fact:
 
 Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
 
+## The whole file
+
+The **Source** tab on that screen replaces the outline with the file itself, top to bottom, with the same gutter markers and the same right-hand list of what each line calls — a 6,800-line file scrolls as smoothly as a 60-line one, and the text pages in behind you.
+
+The margin on the left is the part you cannot get anywhere else: **an arc for every call that stays inside the file**, drawn from the calling line to the line the callee is defined on. Source order is the only layout — nothing is placed by an algorithm, because the author already placed it — so the shape of 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; click an arc to jump to the other end. On a file with more than forty of them the diagram narrows to the symbol you are reading rather than drawing a wash of overlapping sweeps, and the count stays 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.
+
 ## The flow
 
 Type **"how does execute reach getFile"** into the search box — or `execute -> getFile` — and the first result opens the **Flow** strip: the call path between the two symbols, left to right, one card per hop.

+ 35 - 0
src/db/queries.ts

@@ -246,6 +246,7 @@ export class QueryBuilder {
     getEdgesBySource?: SqliteStatement;
     getEdgesByTarget?: SqliteStatement;
     getUnresolvedFromNode?: SqliteStatement;
+    getUnresolvedInFile?: SqliteStatement;
     insertFile?: SqliteStatement;
     updateFile?: SqliteStatement;
     deleteFile?: SqliteStatement;
@@ -2230,6 +2231,40 @@ export class QueryBuilder {
       .all(minConfidence) as Array<{ source: string; target: string }>;
   }
 
+  /**
+   * Every unresolved reference recorded in one FILE, ordered by line.
+   *
+   * The per-symbol form above answers "what does this body reach that the
+   * index does not hold". A whole-file reader asks the same question of every
+   * line at once, and asking it one symbol at a time is a query per symbol —
+   * 153 of them on this repo's largest file. `unresolved_refs.file_path` is
+   * indexed, so this is one lookup whatever the file holds.
+   *
+   * `limit` bounds the answer rather than the work: the caller draws a marker
+   * per row, and a generated file with fifty thousand of them would ship
+   * megabytes to say something a count already says. Rows come back in line
+   * order, so a cap trims the END of the file, which is at least legible.
+   */
+  getUnresolvedReferencesInFile(filePath: string, limit = 5000): UnresolvedReference[] {
+    if (!this.stmts.getUnresolvedInFile) {
+      this.stmts.getUnresolvedInFile = this.db.prepare(
+        'SELECT * FROM unresolved_refs WHERE file_path = ? ORDER BY line, col LIMIT ?'
+      );
+    }
+    const rows = this.stmts.getUnresolvedInFile.all(filePath, limit) as UnresolvedRefRow[];
+    return rows.map((row) => ({
+      fromNodeId: row.from_node_id,
+      referenceName: row.reference_name,
+      referenceKind: row.reference_kind as EdgeKind,
+      line: row.line,
+      column: row.col,
+      candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
+      filePath: row.file_path,
+      language: row.language as Language,
+      rowId: row.id,
+    }));
+  }
+
   /**
    * 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

+ 11 - 0
src/index.ts

@@ -1438,6 +1438,17 @@ export class CodeGraph {
     return this.queries.getUnresolvedReferencesFrom(nodeId);
   }
 
+  /**
+   * The same, for every symbol in a FILE at once, in line order.
+   *
+   * One indexed lookup instead of one per symbol — the whole-file reader needs
+   * it for every line it draws. See
+   * {@link QueryBuilder.getUnresolvedReferencesInFile}.
+   */
+  getUnresolvedReferencesInFile(filePath: string, limit?: number): UnresolvedReference[] {
+    return this.queries.getUnresolvedReferencesInFile(filePath, limit);
+  }
+
   /**
    * Get all nodes in a file
    */

+ 53 - 29
src/ui-server/api/file.ts

@@ -71,34 +71,7 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
   // ---------------------------------------------------------------------------
   // Outline
   // ---------------------------------------------------------------------------
-  const containsEdges = cg.getOutgoingEdgesFrom(nodeIds, ['contains']);
-  const parentOf = new Map<string, string>();
-  for (const edge of containsEdges) {
-    // Only nesting *within* this file: a `contains` edge reaching out of it is
-    // not something a file outline can draw.
-    if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
-      parentOf.set(edge.target, edge.source);
-    }
-  }
-
-  const fanIn = cg.getFanIn(nodeIds);
-  const fanOut = cg.getFanOut(nodeIds);
-
-  const outlineNodes = nodes
-    // The file node is the subject of the screen, not a row in its own outline;
-    // import declarations get their own rail and would otherwise be most of it.
-    .filter((n) => n.kind !== 'file' && n.kind !== 'import')
-    .sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
-
-  const outline: WireOutlineEntry[] = outlineNodes
-    .slice(0, MAX_OUTLINE_NODES)
-    .map((node) => ({
-      ...toNodeRef(node),
-      parentId: resolveOutlineParent(node.id, parentOf, fileNode?.id),
-      depth: depthOf(node.id, parentOf, fileNode?.id),
-      fanIn: fanIn.get(node.id) ?? 0,
-      fanOut: fanOut.get(node.id) ?? 0,
-    }));
+  const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
 
   // ---------------------------------------------------------------------------
   // Import rails
@@ -162,7 +135,7 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
     },
     /** The file changed on disk since it was indexed — the outline's lines may be shifted. */
     drift: hasDriftedOnDisk(projectRoot, storedPath, record),
-    outline: wireList(outline, outlineNodes.length),
+    outline: wireList(outline, outlineTotal),
     imports: wireList(imports.slice(0, MAX_IMPORT_FILES), imports.length),
     importedBy: wireList(importedBy.slice(0, MAX_IMPORT_FILES), importedBy.length),
     unresolvedImports,
@@ -177,6 +150,57 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
   };
 }
 
+/**
+ * A file's symbols in source order, nested under their container.
+ *
+ * Extracted so the whole-file source view (`/api/filecode`) draws the same rows
+ * as the outline view rather than a second, subtly different reading of the
+ * same `contains` edges — an outline rail whose line numbers disagreed with the
+ * source beside it would be worse than no rail.
+ *
+ * Four batched queries whatever the file holds: its nodes are already in hand,
+ * their `contains` edges, and fan-in / fan-out for the whole set at once.
+ *
+ * @returns the capped rows and the TRUE symbol count, which is what a header
+ *          has to print — see `wireList`.
+ */
+export function buildOutlineEntries(
+  cg: CodeGraph,
+  nodes: readonly Node[]
+): { entries: WireOutlineEntry[]; total: number } {
+  const nodeIds = nodes.map((n) => n.id);
+  const inThisFile = new Set(nodeIds);
+  const fileNodeId = nodes.find((n) => n.kind === 'file')?.id;
+
+  const parentOf = new Map<string, string>();
+  for (const edge of cg.getOutgoingEdgesFrom(nodeIds, ['contains'])) {
+    // Only nesting *within* this file: a `contains` edge reaching out of it is
+    // not something a file outline can draw.
+    if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
+      parentOf.set(edge.target, edge.source);
+    }
+  }
+
+  const fanIn = cg.getFanIn(nodeIds);
+  const fanOut = cg.getFanOut(nodeIds);
+
+  const outlineNodes = nodes
+    // The file node is the subject of the screen, not a row in its own outline;
+    // import declarations get their own rail and would otherwise be most of it.
+    .filter((n) => n.kind !== 'file' && n.kind !== 'import')
+    .sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
+
+  const entries: WireOutlineEntry[] = outlineNodes.slice(0, MAX_OUTLINE_NODES).map((node) => ({
+    ...toNodeRef(node),
+    parentId: resolveOutlineParent(node.id, parentOf, fileNodeId),
+    depth: depthOf(node.id, parentOf, fileNodeId),
+    fanIn: fanIn.get(node.id) ?? 0,
+    fanOut: fanOut.get(node.id) ?? 0,
+  }));
+
+  return { entries, total: outlineNodes.length };
+}
+
 /**
  * The outline parent of a symbol: its container within the file, or null when
  * that container is the file node itself (a top-level symbol has no parent row).

+ 297 - 0
src/ui-server/api/filecode.ts

@@ -0,0 +1,297 @@
+/**
+ * `GET /api/filecode/<path>` — the whole-file source view in one round-trip.
+ *
+ * The Symbol view asks "what does this body reach"; this screen asks the same
+ * question of every line of a file at once, and answers it beside the file's
+ * own source. What that needs is one payload holding everything the graph says
+ * about lines in this file, and nothing that depends on scroll position:
+ *
+ * * the file's symbols in source order — the sticky outline rail, and the
+ *   definition line every intra-file arc lands on,
+ * * one row per (calling symbol, called symbol) pair, carrying the call-site
+ *   lines the gutter ports and the callee rail anchor to,
+ * * the references that never resolved, so a line that reaches `console.log`
+ *   still shows a hollow port instead of an empty gutter that reads as
+ *   "nothing happens here",
+ * * the file's total line count, which IS the layout: every line is a fixed
+ *   height, so the viewer can size a 6 800-line document and start drawing
+ *   before a single page of source has arrived.
+ *
+ * The source itself does NOT ride along. A 6 800-line TypeScript file is ~1.5 s
+ * of TextMate tokenising and megabytes of JSON; the viewer pages it through
+ * `/api/source` as the reader scrolls, which is also what lets the graph
+ * facts — ports, arcs, rail rows — be complete from the first frame while the
+ * text fills in behind them.
+ *
+ * **The arcs are not a separate list.** An arc is a call whose target is
+ * defined in this same file, so the viewer derives them from `calls` and the
+ * `intraFileCalls` count here is computed from the SHOWN groups for the same
+ * reason: a header that counted raw edges would disagree with the picture under
+ * it the moment a cap bit.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { Edge, Node } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+import { buildOutlineEntries, type WireOutlineEntry } from './file';
+import { readFileShape, resolveRequestedFile } from './source';
+import { badRequest } from './respond';
+import {
+  firstLine,
+  groupRelations,
+  toPosixPath,
+  wireList,
+  type WireList,
+  type WireRelation,
+} from './wire';
+
+/**
+ * Call groups returned for one file.
+ *
+ * A generous cap, not a display budget: the viewer only ever draws the rows in
+ * the window it is scrolled to, so the number that matters is what a payload
+ * costs to ship. This repo's largest file (`src/mcp/tools.ts`, 6 820 lines)
+ * produces 498.
+ */
+export const MAX_FILE_CALL_GROUPS = 2000;
+
+/**
+ * Unresolved references returned for one file.
+ *
+ * These are markers, not rows — each one is a hollow port and a soft underline
+ * with nothing behind it. `src/mcp/tools.ts` has 1 113; a generated bundle can
+ * have tens of thousands, and past this point the count says everything the
+ * list would.
+ */
+export const MAX_FILE_OUTSIDE_REFS = 3000;
+
+/**
+ * Unresolved-reference rows read before the count itself becomes a floor.
+ *
+ * `total` has to be the real number — the rest of this API guarantees that a
+ * count equals a list — and the filter below (plain identifiers only) is not
+ * expressible in SQL, so the rows have to be scanned to be counted. This is the
+ * backstop against a generated bundle with a million of them, and it is far
+ * above anything hand-written: the largest file in this repo's own index has
+ * 1 113.
+ */
+export const MAX_FILE_OUTSIDE_SCAN = 50_000;
+
+/** A reference the resolver never landed: a port with no destination. */
+export interface WireFileOutsideRef {
+  line: number;
+  col: number;
+  /** The identifier as written — how the viewer finds the token to underline. */
+  name: string;
+  kind: string;
+}
+
+/** Every edge from ONE symbol in this file to ONE symbol anywhere. */
+export interface WireFileCall {
+  /**
+   * The symbol in this file that makes the calls.
+   *
+   * Never null: extraction records a statement outside every definition as an
+   * edge out of the FILE node, so top-level code has an owner too — the file
+   * itself.
+   */
+  ownerId: string;
+  /** First line of the owner's definition, so a rail row can be attributed. */
+  ownerLine: number;
+  relation: WireRelation;
+}
+
+export interface WireFileCodePayload {
+  file: {
+    path: string;
+    language: string;
+    size: number;
+    indexedAt: number;
+    contentHash: string;
+    generated: boolean;
+    test: boolean;
+    errors: string[];
+    /** The file node's own id — the owner of every top-level call. */
+    id: string | null;
+    /**
+     * Lines on disk right now. Null when the file could not be read, which is
+     * the one case the viewer cannot lay out and says so.
+     */
+    totalLines: number | null;
+  };
+  /** The file changed on disk since it was indexed — every line number is suspect. */
+  drift: boolean;
+  /** Why, when there is something to say beyond the flag. */
+  reason?: string;
+  /** The file's symbols in source order — the same rows `/api/file` draws. */
+  outline: WireList<WireOutlineEntry>;
+  /** One row per (calling symbol, called symbol) pair, in call-site order. */
+  calls: WireList<WireFileCall>;
+  /** References with nothing behind them — hollow ports. */
+  outside: WireList<WireFileOutsideRef>;
+  /**
+   * Calls landing on a definition in THIS file — the arc diagram's total.
+   *
+   * Counted over the groups actually returned, so it always equals the number
+   * of arcs the viewer can draw from this payload.
+   */
+  intraFileCalls: number;
+  timing: { elapsedMs: number };
+}
+
+export function buildFileCode(
+  cg: CodeGraph,
+  projectRoot: string,
+  requested: string
+): WireFileCodePayload {
+  const started = Date.now();
+  if (requested === '') throw badRequest('No file path was given. Use /api/filecode/<path>.');
+
+  // Refusal first, index lookup second — see `resolveRequestedFile`.
+  const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
+  const posixPath = toPosixPath(storedPath);
+
+  const nodes = cg.getNodesInFile(storedPath);
+  const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
+  const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
+
+  const { calls, total: callTotal, intraFileCalls } = buildCalls(cg, nodes, posixPath);
+  const outside = buildOutsideRefs(cg, storedPath);
+
+  // One read answers both the drift verdict and the document's height.
+  const shape = readFileShape(projectRoot, storedPath, record);
+
+  return {
+    file: {
+      path: posixPath,
+      language: record.language,
+      size: record.size,
+      indexedAt: record.indexedAt,
+      contentHash: record.contentHash,
+      generated: record.generated === true,
+      test: isTestFile(posixPath),
+      // Messages, not the raw records: the screen prints a count and a line,
+      // and an extractor's file/line bookkeeping is not something a reader acts
+      // on.
+      errors: (record.errors ?? []).map((e) => e.message),
+      id: fileNode?.id ?? null,
+      totalLines: shape.totalLines,
+    },
+    drift: shape.drift,
+    ...(shape.reason ? { reason: shape.reason } : {}),
+    outline: wireList(outline, outlineTotal),
+    calls: wireList(calls, callTotal),
+    outside: wireList(outside.items, outside.total),
+    intraFileCalls,
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+/**
+ * Every outgoing edge from every symbol in the file, grouped twice over: by the
+ * symbol that makes the call, and within that by the symbol it reaches.
+ *
+ * Grouping by the OWNER as well as the target is what separates this from the
+ * Symbol view's rail. Across one body, a helper called from three lines is one
+ * row with `×3` and one place to sit. Across a 6 800-line file, the same helper
+ * called from two different functions a thousand lines apart cannot be one row
+ * — a row is anchored to a line, and there is no line that is both. So the pair
+ * is the unit, and the rail reads in source order the way the file does.
+ *
+ * `contains` is excluded, as everywhere else: it is structure, not dependency,
+ * and the outline already draws it.
+ */
+function buildCalls(
+  cg: CodeGraph,
+  nodes: readonly Node[],
+  posixPath: string
+): { calls: WireFileCall[]; total: number; intraFileCalls: number } {
+  const nodeIds = nodes.map((n) => n.id);
+  const lineOf = new Map(nodes.map((n) => [n.id, n.startLine] as const));
+
+  const edges = cg.getOutgoingEdgesFrom(nodeIds).filter((e) => e.kind !== 'contains');
+  if (edges.length === 0) return { calls: [], total: 0, intraFileCalls: 0 };
+
+  const bySource = new Map<string, Edge[]>();
+  for (const edge of edges) {
+    const bucket = bySource.get(edge.source);
+    if (bucket) bucket.push(edge);
+    else bySource.set(edge.source, [edge]);
+  }
+
+  // One batched lookup for every counterpart, never one per edge: the engine's
+  // busiest file reaches several hundred distinct symbols.
+  const endpoints = cg.getNodesByIds([...new Set(edges.map((e) => e.target))]);
+
+  const all: WireFileCall[] = [];
+  for (const [ownerId, group] of bySource) {
+    for (const relation of groupRelations(group, (e) => e.target, endpoints)) {
+      all.push({ ownerId, ownerLine: lineOf.get(ownerId) ?? 0, relation });
+    }
+  }
+
+  // Source order — the only ordering this screen has. A row with no recorded
+  // call site (an edge the extractor gave no line) sorts to the end, where it
+  // is also what a cap trims first.
+  all.sort(
+    (a, b) =>
+      firstLine(a.relation) - firstLine(b.relation) ||
+      a.ownerLine - b.ownerLine ||
+      a.relation.node.name.localeCompare(b.relation.node.name)
+  );
+
+  const calls = all.slice(0, MAX_FILE_CALL_GROUPS);
+
+  // Arcs, counted over what was KEPT — see the module comment.
+  let intraFileCalls = 0;
+  for (const call of calls) {
+    if (call.relation.node.file !== posixPath) continue;
+    const target = call.relation.node.line;
+    for (const line of call.relation.lines) if (line !== target) intraFileCalls++;
+  }
+
+  return { calls, total: all.length, intraFileCalls };
+}
+
+/**
+ * The file's unresolved references, as line markers.
+ *
+ * Only plain identifiers survive. The resolver's samples are bookkeeping, and a
+ * "name" that is really a whole arrow function or a receiver expression cannot
+ * be matched to a token on the line — a marker that could not find its
+ * identifier would silently claim the wrong one, which is worse than no marker.
+ * The same filter the Symbol view applies, applied once here rather than per
+ * symbol.
+ */
+function buildOutsideRefs(
+  cg: CodeGraph,
+  storedPath: string
+): { items: WireFileOutsideRef[]; total: number } {
+  let raw;
+  try {
+    // Scanned, not capped at the display limit: `total` must be the real count
+    // and the identifier filter below cannot run in SQL.
+    raw = cg.getUnresolvedReferencesInFile(storedPath, MAX_FILE_OUTSIDE_SCAN);
+  } catch {
+    return { items: [], total: 0 };
+  }
+
+  const items: WireFileOutsideRef[] = [];
+  let total = 0;
+  for (const ref of raw) {
+    const name = lastSegment(ref.referenceName ?? '');
+    if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
+    if (!ref.line) continue;
+    total++;
+    if (items.length < MAX_FILE_OUTSIDE_REFS) {
+      items.push({ line: ref.line, col: ref.column ?? 0, name, kind: ref.referenceKind });
+    }
+  }
+  return { items, total };
+}
+
+/** The trailing segment of a dotted name — what actually appears in the source. */
+function lastSegment(name: string): string {
+  const dot = name.lastIndexOf('.');
+  return dot < 0 ? name : name.slice(dot + 1);
+}

+ 25 - 1
src/ui-server/api/index.ts

@@ -1,7 +1,7 @@
 /**
  * The read-only JSON API the viewer reads its screens from.
  *
- * Ten endpoints, one per screen, each answering in a single round-trip — the
+ * Eleven endpoints, one per screen, each answering in a single round-trip — the
  * 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
  * existing schema; nothing indexes, resolves, or writes.
@@ -13,6 +13,7 @@
  * 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/file/<path>               the File view: outline and import rails
+ * GET /api/filecode/<path>           the whole-file view: ports, arcs, callee rail
  * GET /api/routes                    the URL to handler map, when there is one
  * GET /api/entrypoints               where to start reading: routes, roots, hubs
  * GET /api/map?root=&depth=          the module map: modules, links, cycles
@@ -36,6 +37,7 @@ import { buildSearch } from './search';
 import { buildNode } from './node';
 import { buildSource } from './source';
 import { buildFile } from './file';
+import { buildFileCode } from './filecode';
 import { buildRoutes } from './routes';
 import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
@@ -56,6 +58,11 @@ export type {
   WireFlowCallRef,
   WireFlowAmbiguity,
 } from './flow';
+export type {
+  WireFileCodePayload,
+  WireFileCall,
+  WireFileOutsideRef,
+} from './filecode';
 export type {
   WireMapPayload,
   WireMapModule,
@@ -94,6 +101,11 @@ const API_INDEX = {
       params: ['file', 'from', 'to'],
     },
     { path: '/api/file/<path>', description: 'One file: outline and import rails.' },
+    {
+      path: '/api/filecode/<path>',
+      description:
+        'One file, line by line: call sites, unresolved references and the calls that stay inside it.',
+    },
     { path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
     {
       path: '/api/map',
@@ -178,6 +190,14 @@ function dispatchPathRoutes(
     return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
   }
 
+  // Before `/api/file/`: that prefix is not a prefix of this route, but keeping
+  // the more specific one first means adding another `/api/file…` sibling later
+  // cannot silently start matching the shorter one.
+  const codePath = suffixAfter(route, '/api/filecode/');
+  if (codePath !== null) {
+    return ok(res, buildFileCode(session.acquire(), ctx.projectRoot, codePath), ctx.method);
+  }
+
   const filePath = suffixAfter(route, '/api/file/');
   if (filePath !== null) {
     if (filePath === '') throw badRequest('No file path was given. Use /api/file/<path>.');
@@ -186,6 +206,10 @@ function dispatchPathRoutes(
 
   // `/api/node` and `/api/file` with no argument at all, so the message can say
   // what the endpoint wants instead of falling through to a bare 404.
+  if (route === '/api/filecode') {
+    throw badRequest('/api/filecode needs an argument: /api/filecode/<path>.');
+  }
+
   if (route === '/api/node' || route === '/api/file') {
     throw badRequest(`${route} needs an argument: ${route}/<${route.endsWith('node') ? 'id' : 'path'}>.`);
   }

+ 54 - 0
src/ui-server/api/source.ts

@@ -173,6 +173,60 @@ export function hasDriftedOnDisk(
   }
 }
 
+/**
+ * The drift verdict AND the file's length, from one read.
+ *
+ * The whole-file view needs both before it draws anything: the drift banner,
+ * and the line count that fixes the height of the scrolling document (every
+ * line is a fixed 20px, so the total IS the layout). Asking
+ * {@link hasDriftedOnDisk} and then a source page would answer the first
+ * question against one read of the file and the second against another, which
+ * is exactly the window in which a file can change underneath the two.
+ *
+ * Unlike `hasDriftedOnDisk` there is no stat-only fast path: the bytes have to
+ * be read to be counted. That is the cost of knowing the length, and it is
+ * bounded by {@link MAX_SOURCE_BYTES} like every other read here.
+ */
+export function readFileShape(
+  projectRoot: string,
+  storedPath: string,
+  record: FileRecord
+): { drift: boolean; totalLines: number | null; reason?: string } {
+  let absolute: string;
+  try {
+    absolute = resolveProjectFile(projectRoot, storedPath);
+  } catch {
+    // A refusal on a path the INDEX handed us is not a request to refuse — the
+    // caller already passed the chokepoint. Treat it as unreadable.
+    return { drift: false, totalLines: null };
+  }
+  try {
+    const stats = fs.statSync(absolute);
+    if (stats.size > MAX_SOURCE_BYTES) {
+      return { drift: false, totalLines: null, reason: 'The file is too large to read here.' };
+    }
+    const content = fs.readFileSync(absolute, 'utf-8');
+    const drift = createHash('sha256').update(content).digest('hex') !== record.contentHash;
+    return {
+      drift,
+      totalLines: splitLines(content).length,
+      ...(drift
+        ? {
+            reason:
+              'This file changed on disk after the last index sync, so the line ' +
+              'numbers the graph holds no longer match it.',
+          }
+        : {}),
+    };
+  } catch {
+    return {
+      drift: true,
+      totalLines: null,
+      reason: 'The file is in the index but could not be read from disk.',
+    };
+  }
+}
+
 export interface SourceResult {
   file: string;
   language: string;

+ 3 - 1
ui/README.md

@@ -44,6 +44,7 @@ src/
   lib/kinds.ts            kind glyph letters
   lib/map-model.ts        the Map's deterministic layered layout (pure)
   lib/flow-model.ts       the Flow strip's card/link geometry — a DAG (pure)
+  lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
   components/             TopBar, TrailBar, KindGlyph, map/, flow/, symbol/, file/
   views/                  one component per route
 ```
@@ -58,7 +59,8 @@ announce the project to a font CDN.
 |---|---|
 | `#/` | nothing selected |
 | `#/s/<id>?hl=<line>&t=<trail>` | symbol view |
-| `#/file/<path>?hl=<line>` | file view |
+| `#/file/<path>?hl=<line>` | file view — outline in source order |
+| `#/file/<path>?src=1` | file view — the whole file's source, with ports and call arcs |
 | `#/map?root=&depth=&tests=1` | module map |
 | `#/flow?from=&to=` | flow strip — the call path between two symbols |
 | `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |

+ 3 - 0
ui/src/App.svelte

@@ -5,6 +5,7 @@
   import HomeView from './views/HomeView.svelte';
   import SymbolView from './views/SymbolView.svelte';
   import FileView from './views/FileView.svelte';
+  import FileCodeView from './views/FileCodeView.svelte';
   import MapView from './views/MapView.svelte';
   import FlowView from './views/FlowView.svelte';
   import NotFoundView from './views/NotFoundView.svelte';
@@ -95,6 +96,8 @@
 <main>
   {#if route.view === 'symbol'}
     <SymbolView id={route.id} line={route.line} />
+  {:else if route.view === 'file' && route.source}
+    <FileCodeView path={route.path} line={route.line} />
   {:else if route.view === 'file'}
     <FileView path={route.path} line={route.line} />
   {:else if route.view === 'map'}

+ 126 - 0
ui/src/components/file/CodeArcs.svelte

@@ -0,0 +1,126 @@
+<!--
+  The intra-file call arcs — the left margin of the whole-file view
+  (design spec §3.4, task CG-52).
+
+  One arc per call whose callee is defined in this same file, drawn from the
+  calling line to the definition line. This is the one place in the app where a
+  "graph of the file" is legible, and the reason is that it is not a graph
+  drawing: the nodes were placed by whoever wrote the file, in source order, and
+  an arc only has to say which two lines are joined.
+
+  Every arc gets a second, invisible, ten-pixel-wide path on top of it. A 1px
+  hairline is not a click target, and the whole point of the picture is that you
+  can grab one and go to the other end.
+-->
+<script lang="ts">
+  import { hot } from '../../lib/focus.svelte';
+  import { ARC_COLUMN, type FileArc } from '../../lib/filecode-model';
+
+  interface Props {
+    /** Already filtered and windowed by the view. */
+    arcs: FileArc[];
+    /** Height of the scrolling document — the SVG spans all of it. */
+    height: number;
+    /** The line under the pointer, so an arc leaving or landing on it lights. */
+    hoverLine: number | null;
+    onfollow: (arc: FileArc) => void;
+  }
+
+  let { arcs, height, hoverLine, onfollow }: Props = $props();
+
+  /**
+   * Accent is for the POINTER, never for the filter.
+   *
+   * On a crowded file the view has already narrowed the set to the focused
+   * symbol's arcs; colouring those as well would light every line on screen and
+   * say nothing. The spec's rule is the one that carries information: an arc
+   * goes accent when its line or its callee is under the pointer.
+   */
+  function isLit(arc: FileArc): boolean {
+    return hot.is(arc.targetId) || arc.fromLine === hoverLine || arc.toLine === hoverLine;
+  }
+
+  function label(arc: FileArc): string {
+    const direction = arc.toLine < arc.fromLine ? 'above' : 'below';
+    return `line ${arc.fromLine} calls ${arc.targetName}, defined ${direction} at line ${arc.toLine}`;
+  }
+</script>
+
+<svg
+  class="arcs"
+  width={ARC_COLUMN}
+  {height}
+  viewBox={`0 0 ${ARC_COLUMN} ${height}`}
+  focusable="false"
+  aria-hidden="true"
+>
+  {#each arcs as arc (arc.key)}
+    <path
+      class="arc"
+      class:lit={isLit(arc)}
+      class:uncertain={arc.uncertain}
+      class:heur={arc.synthesized}
+      d={arc.d}
+    />
+    <!-- Not in the tab order, and the SVG is aria-hidden: every arc's callee is
+         also a focusable rail row and a focusable call-site link in the body, so
+         the picture is a redundant affordance rather than the only one. -->
+    <path
+      class="hit"
+      d={arc.d}
+      role="button"
+      tabindex="-1"
+      onclick={() => onfollow(arc)}
+      onkeydown={(e) => {
+        if (e.key === 'Enter' || e.key === ' ') {
+          e.preventDefault();
+          onfollow(arc);
+        }
+      }}
+      onmouseenter={() => hot.set(arc.targetId)}
+      onmouseleave={() => hot.clear(arc.targetId)}
+    >
+      <title>{label(arc)}</title>
+    </path>
+  {/each}
+</svg>
+
+<style>
+  .arcs {
+    position: absolute;
+    top: 0;
+    left: 0;
+    overflow: visible;
+  }
+
+  .arc {
+    fill: none;
+    stroke: var(--ink-4);
+    stroke-width: 1;
+    pointer-events: none;
+  }
+
+  .arc.uncertain {
+    stroke-dasharray: 2 3;
+  }
+
+  /* Synthesized rather than parsed — dynamic dispatch the parser cannot see.
+     Same dash the Symbol view's connectors use for the same claim. */
+  .arc.heur {
+    stroke: var(--ink-3);
+    stroke-dasharray: 6 3;
+  }
+
+  .arc.lit {
+    stroke: var(--accent);
+    stroke-width: 1.5;
+  }
+
+  /* The hairline is not a click target; this is. */
+  .hit {
+    fill: none;
+    stroke: transparent;
+    stroke-width: 10;
+    cursor: pointer;
+  }
+</style>

+ 273 - 0
ui/src/components/file/FileCodeBlock.svelte

@@ -0,0 +1,273 @@
+<!--
+  The whole file's source, virtualised, with a gutter port on every line the
+  graph has an edge from (design spec §3.4, task CG-52).
+
+  The same line grid as the Symbol view — `44px | 1fr | 18px`, the same 6x6
+  port, the same accent call-site links — with one difference that changes
+  everything about how it is built: lines are ABSOLUTELY POSITIONED at
+  `lineTop(n)` rather than stacked. That is what lets a 6 820-line file hold
+  ninety-odd elements instead of six thousand, and what lets an arc drawn beside
+  it know where line 4 271 is without asking the browser.
+
+  A page of source that has not arrived yet still draws: its line number, its
+  port and its place in the document are all facts from the graph, not from the
+  text. Only the characters are missing, and they fill in behind the reader.
+-->
+<script lang="ts">
+  import { tokenClass, type Token } from '../../lib/highlight';
+  import { assignRefs, type LineRef } from '../../lib/symbol-model';
+  import { lineTop } from '../../lib/filecode-model';
+  import { hot } from '../../lib/focus.svelte';
+
+  interface Props {
+    /** Inclusive 1-based range to render. */
+    first: number;
+    last: number;
+    /** Classified source for a line, or null while its page is in flight. */
+    tokensFor: (line: number) => Token[] | null;
+    refs: Map<number, LineRef[]>;
+    /** Lines a definition starts on → its name, set in bold there. */
+    defNames: Map<number, string>;
+    /** The line `?hl=` or an arc click landed on. */
+    highlight: number | null;
+    onfollow: (ref: LineRef) => void;
+    onhoverline: (line: number | null) => void;
+  }
+
+  let { first, last, tokensFor, refs, defNames, highlight, onfollow, onhoverline }: Props =
+    $props();
+
+  interface Part {
+    text: string;
+    cls: string | null;
+    ref: LineRef | null;
+    def: boolean;
+  }
+
+  interface RenderedLine {
+    n: number;
+    top: number;
+    parts: Part[] | null;
+    port: 'sure' | 'unsure' | null;
+    targets: string[];
+  }
+
+  let lines = $derived.by<RenderedLine[]>(() => {
+    const out: RenderedLine[] = [];
+    for (let n = first; n <= last; n++) {
+      const lineRefs = refs.get(n) ?? [];
+      const tokens = tokensFor(n);
+      out.push({
+        n,
+        top: lineTop(n),
+        parts: tokens ? toParts(tokens, assignRefs(tokens, lineRefs), defNames.get(n) ?? null) : null,
+        port: portFor(lineRefs),
+        targets: [...new Set(lineRefs.map((r) => r.targetId).filter((id): id is string => !!id))],
+      });
+    }
+    return out;
+  });
+
+  function toParts(line: Token[], claimed: Map<number, LineRef>, definition: string | null): Part[] {
+    return line.map((token, index) => {
+      const ref = claimed.get(index) ?? null;
+      return {
+        text: token.text,
+        cls: ref ? null : tokenClass(token.cls),
+        ref,
+        def:
+          !ref &&
+          definition !== null &&
+          token.text === definition &&
+          token.cls !== 'comment' &&
+          token.cls !== 'string',
+      };
+    });
+  }
+
+  /**
+   * Filled = the graph resolved something here; hollow = it only guessed, or
+   * the reference left the index. No edge at all means no port: absence is the
+   * signal, so an empty gutter must stay empty.
+   */
+  function portFor(lineRefs: readonly LineRef[]): 'sure' | 'unsure' | null {
+    if (lineRefs.length === 0) return null;
+    return lineRefs.some((r) => !r.uncertain && !r.outside) ? 'sure' : 'unsure';
+  }
+
+  function isHot(line: RenderedLine): boolean {
+    return line.n === highlight || line.targets.some((id) => hot.is(id));
+  }
+</script>
+
+<div class="code">
+  {#each lines as line (line.n)}
+    <div
+      class="ln"
+      class:hot={isHot(line)}
+      style:top={`${line.top}px`}
+      data-line={line.n}
+      onmouseenter={() => onhoverline(line.n)}
+      role="presentation"
+    >
+      <span class="no">{line.n}</span>
+      <span class="tx"
+        >{#if line.parts}{#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref =
+              part.ref}<span
+              class="ref"
+              class:uncertain={ref.uncertain}
+              class:hot={hot.is(ref.targetId)}
+              role="link"
+              tabindex="0"
+              title={ref.title}
+              onclick={() => onfollow(ref)}
+              onkeydown={(e) => {
+                if (e.key === 'Enter' || e.key === ' ') {
+                  e.preventDefault();
+                  onfollow(ref);
+                }
+              }}
+              onmouseenter={() => hot.set(ref.targetId)}
+              onmouseleave={() => hot.clear(ref.targetId)}>{part.text}</span
+            >{:else if part.ref}<span class="ref stub" title={part.ref.title}>{part.text}</span
+            >{:else if part.def}<span class="t-def">{part.text}</span
+            >{:else if part.cls}<span class={part.cls}>{part.text}</span
+            >{:else}{part.text}{/if}{/each}{:else}<span class="pending"></span>{/if}</span
+      >
+      <span class="port">
+        {#if line.port}<i class:sure={line.port === 'sure'}></i>{/if}
+      </span>
+    </div>
+  {/each}
+</div>
+
+<style>
+  .code {
+    position: absolute;
+    inset: 0;
+    font: var(--code-size) / var(--code-lh) var(--mono);
+  }
+
+  /* 44px gutter | source | 18px port cell — the Symbol view's grid.
+
+     The HEIGHT here is load-bearing: every arc, connector and rail row on this
+     screen is placed at `CODE_TOP_PAD + (line - 1) * CODE_LINE_HEIGHT`, and a
+     line that grew past it would detach all three from the source at once.
+     `filecode-model.ts` holds the constant; this is the other half of it. */
+  .ln {
+    position: absolute;
+    right: 0;
+    left: 0;
+    display: grid;
+    height: 20px;
+    box-sizing: border-box;
+    grid-template-columns: 44px 1fr 18px;
+    align-items: stretch;
+  }
+
+  .ln:hover {
+    background: var(--paper-2);
+  }
+
+  .ln.hot {
+    background: var(--accent-soft);
+  }
+
+  .no {
+    padding-right: 12px;
+    color: var(--ink-4);
+    font-size: 11px;
+    text-align: right;
+    user-select: none;
+  }
+
+  .tx {
+    overflow: hidden;
+    white-space: pre;
+  }
+
+  /* A line whose page is still in flight. It keeps its number, its port and its
+     place; only the characters are missing. */
+  .pending {
+    display: inline-block;
+    width: 34%;
+    height: 8px;
+    background: var(--rule-faint);
+    vertical-align: middle;
+  }
+
+  .port {
+    position: relative;
+  }
+
+  .port i {
+    position: absolute;
+    top: 7px;
+    right: 4px;
+    width: 6px;
+    height: 6px;
+    border: 1px solid var(--ink-3);
+    background: var(--paper);
+  }
+
+  .port i.sure {
+    background: var(--ink-3);
+  }
+
+  .ln.hot .port i {
+    border-color: var(--accent);
+    background: var(--accent);
+  }
+
+  /* ---- token classes (near-monochrome by design, spec §2.2) ---- */
+  .t-c {
+    color: var(--code-comment);
+  }
+
+  .t-s {
+    color: var(--ink-2);
+  }
+
+  .t-k {
+    font-weight: 500;
+  }
+
+  .t-n {
+    color: var(--ink-2);
+  }
+
+  .t-def {
+    font-weight: 600;
+  }
+
+  .ref {
+    color: var(--accent);
+    cursor: pointer;
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+
+  .ref:hover,
+  .ref.hot {
+    background: var(--accent-soft);
+    text-decoration-color: var(--accent);
+  }
+
+  .ref.uncertain {
+    color: var(--ink-2);
+    text-decoration-style: dotted;
+    text-decoration-color: var(--ink-4);
+  }
+
+  .ref.stub {
+    color: var(--ink-2);
+    cursor: default;
+    text-decoration-color: var(--rule-soft);
+  }
+
+  .ref.stub:hover {
+    background: none;
+    text-decoration-color: var(--rule-soft);
+  }
+</style>

+ 181 - 0
ui/src/components/file/FileCodeOutline.svelte

@@ -0,0 +1,181 @@
+<!--
+  The sticky navigation rail beside the whole-file source (design spec §3.4,
+  task CG-52). Shown when there is room for it — under 1400px the view drops it
+  rather than squeezing the code.
+
+  The same rows as the File view's outline, at navigation weight: a click
+  scrolls the source to that definition rather than leaving the file. Rows are a
+  fixed height and the list is windowed above a threshold, for the same reason
+  the outline view's is — this repo's own fixtures hold a 1 681-symbol `.d.ts`.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import { OUTLINE_VIRTUAL_THRESHOLD, type OutlineEntryRow } from '../../lib/file-model';
+  import { NAV_ROW_HEIGHT } from '../../lib/filecode-model';
+
+  interface Props {
+    rows: OutlineEntryRow[];
+    total: number;
+    truncated: boolean;
+    /** Id of the symbol the reader is inside, from the scroll position. */
+    currentId: string | null;
+    ongo: (line: number, id: string) => void;
+  }
+
+  let { rows, total, truncated, currentId, ongo }: Props = $props();
+
+  let listEl = $state<HTMLDivElement | null>(null);
+  let scrollTop = $state(0);
+  let viewport = $state(0);
+
+  let virtual = $derived(rows.length > OUTLINE_VIRTUAL_THRESHOLD);
+
+  let window_ = $derived.by(() => {
+    if (!virtual) return { start: 0, end: rows.length, before: 0, after: 0 };
+    const first = Math.floor(scrollTop / NAV_ROW_HEIGHT) - 8;
+    const count = Math.ceil((viewport || 800) / NAV_ROW_HEIGHT) + 16;
+    const start = Math.max(0, Math.min(rows.length - 1, first));
+    const end = Math.max(start, Math.min(rows.length, start + count));
+    return {
+      start,
+      end,
+      before: start * NAV_ROW_HEIGHT,
+      after: (rows.length - end) * NAV_ROW_HEIGHT,
+    };
+  });
+
+  // Follow the reader down the file: when the source scrolls into another
+  // symbol, bring its row into the rail rather than making them find it.
+  $effect(() => {
+    const id = currentId;
+    const el = listEl;
+    if (!id || !el) return;
+    const at = rows.findIndex((row) => row.entry.id === id);
+    if (at < 0) return;
+    const top = at * NAV_ROW_HEIGHT;
+    const scroller = el.parentElement;
+    if (!scroller) return;
+    if (top < scroller.scrollTop) scroller.scrollTop = top - NAV_ROW_HEIGHT * 2;
+    else if (top + NAV_ROW_HEIGHT > scroller.scrollTop + scroller.clientHeight) {
+      scroller.scrollTop = top + NAV_ROW_HEIGHT * 3 - scroller.clientHeight;
+    }
+  });
+
+  $effect(() => {
+    const el = listEl?.parentElement;
+    if (!el) return;
+    const read = () => {
+      scrollTop = el.scrollTop;
+      viewport = el.clientHeight;
+    };
+    read();
+    el.addEventListener('scroll', read, { passive: true });
+    const observer = new ResizeObserver(read);
+    observer.observe(el);
+    return () => {
+      el.removeEventListener('scroll', read);
+      observer.disconnect();
+    };
+  });
+</script>
+
+<div class="navrail">
+  <div class="navh">
+    <span>Outline</span>
+    <span class="dim">{total}</span>
+  </div>
+  <div class="navlist">
+    <div bind:this={listEl}>
+      {#if window_.before > 0}<div style:height={`${window_.before}px`}></div>{/if}
+      {#each rows.slice(window_.start, window_.end) as row (row.entry.id)}
+        <button
+          type="button"
+          class="nrow"
+          class:dimmed={row.dimmed}
+          class:current={row.entry.id === currentId}
+          style:padding-left={`${6 + row.indent * 14}px`}
+          title={`${row.entry.qualifiedName} — line ${row.entry.line}`}
+          onclick={() => ongo(row.entry.line, row.entry.id)}
+        >
+          <KindGlyph kind={row.entry.kind} />
+          <span class="nm">{row.entry.name}</span>
+          <span class="ln">{row.entry.line}</span>
+        </button>
+      {/each}
+      {#if window_.after > 0}<div style:height={`${window_.after}px`}></div>{/if}
+    </div>
+  </div>
+  {#if truncated}
+    <div class="note">Showing {rows.length} of {total} symbols.</div>
+  {/if}
+</div>
+
+<style>
+  .navrail {
+    display: grid;
+    grid-template-rows: auto minmax(0, 1fr) auto;
+    height: 100%;
+    border-right: 1px solid var(--rule-soft);
+    background: var(--paper);
+  }
+
+  .navh {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    padding: 12px 12px 8px;
+    border-bottom: 1px solid var(--rule-soft);
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .navlist {
+    min-height: 0;
+    overflow: auto;
+  }
+
+  /* Fixed height — the windowing above assumes it (NAV_ROW_HEIGHT). */
+  .nrow {
+    display: grid;
+    height: 24px;
+    box-sizing: border-box;
+    grid-template-columns: 14px minmax(0, 1fr) auto;
+    width: 100%;
+    align-items: center;
+    gap: 7px;
+    padding-right: 8px;
+    text-align: left;
+  }
+
+  .nrow:hover {
+    background: var(--press);
+  }
+
+  .nrow.current {
+    background: var(--accent-soft);
+  }
+
+  .nm {
+    overflow: hidden;
+    font: 12px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .nrow.dimmed .nm {
+    color: var(--ink-3);
+  }
+
+  .ln {
+    color: var(--ink-4);
+    font: 10.5px var(--mono);
+    font-variant-numeric: tabular-nums;
+  }
+
+  .note {
+    padding: 8px 12px;
+    border-top: 1px solid var(--rule-faint);
+    color: var(--ink-3);
+    font-size: 11px;
+  }
+</style>

+ 152 - 0
ui/src/components/file/FileCodeRail.svelte

@@ -0,0 +1,152 @@
+<!--
+  The whole-file view's callee rail: one row per (calling symbol, called symbol)
+  pair, beside the line that makes the call (design spec §3.4, task CG-52).
+
+  The Symbol view's rail annotates one body. This one annotates a whole file, so
+  the unit is the PAIR rather than the callee: the same helper called from two
+  functions a thousand lines apart is two rows, because a row is anchored to a
+  line and there is no line that is both. `buildFileCallRows` does the placing;
+  the view windows it; this draws what it is handed.
+
+  Uncertain rows are not folded away here. In a single body the fold is what
+  keeps a guess from reading as a resolved call — there is a header to hang it
+  under. Across six thousand lines there is nowhere to put a fold that does not
+  detach it from the source, so a name-only match stays where its call site is
+  and wears the dotted underline that says what it is.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import { hot } from '../../lib/focus.svelte';
+  import type { FileCallRow } from '../../lib/filecode-model';
+  import type { WireNodeRef } from '../../lib/api';
+
+  interface Props {
+    /** Already windowed to the viewport by the view. */
+    rows: FileCallRow[];
+    /** This file's path — a callee inside it reads "same file", not a path. */
+    focalFile: string;
+    /** The symbol whose neighbourhood is lit. */
+    focusId: string | null;
+    onopen: (node: WireNodeRef) => void;
+    onhover: (row: FileCallRow | null) => void;
+  }
+
+  let { rows, focalFile, focusId, onopen, onhover }: Props = $props();
+
+  function title(row: FileCallRow): string {
+    const node = row.call.relation.node;
+    const where = row.lines.length > 0 ? ` · called from line ${row.lines.join(', ')}` : '';
+    return `${node.qualifiedName} — ${node.file}:${node.line}${where}`;
+  }
+</script>
+
+{#each rows as row (row.key)}
+  {@const node = row.call.relation.node}
+  <div
+    class="rrow"
+    class:hot={hot.is(node.id)}
+    class:focused={focusId !== null && (row.ownerId === focusId || node.id === focusId)}
+    class:uncertain={row.call.relation.uncertain}
+    style:top={`${row.top}px`}
+    data-target={node.id}
+    role="button"
+    tabindex="0"
+    title={title(row)}
+    onclick={() => onopen(node)}
+    onkeydown={(e) => {
+      if (e.key === 'Enter' || e.key === ' ') {
+        e.preventDefault();
+        onopen(node);
+      }
+    }}
+    onmouseenter={() => {
+      hot.set(node.id);
+      onhover(row);
+    }}
+    onmouseleave={() => {
+      hot.clear(node.id);
+      onhover(null);
+    }}
+  >
+    <KindGlyph kind={node.kind} />
+    <div class="body">
+      <div class="nm">
+        {node.name}{#if row.lines.length > 1}<span class="dim"> ×{row.lines.length}</span>{/if}
+      </div>
+      <div class="meta">
+        <span>{node.file === focalFile ? 'same file' : node.file}</span>
+        {#if row.words.length > 0}<span>{row.words.join(', ')}</span>{/if}
+        {#if row.via}<span
+            class="tag"
+            title="A synthesized edge — dynamic dispatch the parser cannot see">via {row.via}</span
+          >{/if}
+      </div>
+    </div>
+  </div>
+{/each}
+
+<style>
+  .rrow {
+    position: absolute;
+    right: 12px;
+    left: 14px;
+    display: grid;
+    height: 34px;
+    box-sizing: border-box;
+    grid-template-columns: 16px 1fr;
+    gap: 8px;
+    align-items: center;
+    padding: 0 6px;
+    border: 1px solid transparent;
+    background: var(--paper);
+    cursor: pointer;
+  }
+
+  .rrow:hover {
+    background: var(--press);
+  }
+
+  .rrow.focused {
+    border-color: var(--rule-soft);
+  }
+
+  .rrow.hot {
+    border-color: var(--accent-line);
+    background: var(--accent-soft);
+  }
+
+  .body {
+    min-width: 0;
+  }
+
+  .nm {
+    overflow: hidden;
+    font: 12.5px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .rrow.uncertain .nm {
+    color: var(--ink-2);
+    text-decoration: underline dotted var(--ink-4);
+    text-underline-offset: 3px;
+  }
+
+  .meta {
+    display: flex;
+    gap: 8px;
+    overflow: hidden;
+    color: var(--ink-3);
+    font-size: 11px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .tag {
+    flex: 0 0 auto;
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-3);
+    font-size: 10.5px;
+  }
+</style>

+ 58 - 0
ui/src/components/file/FileModeTabs.svelte

@@ -0,0 +1,58 @@
+<!--
+  Outline or source — the two readings of a file (design spec §3.4).
+
+  A link, not a toggle, because the choice belongs in the URL: a file opened at
+  a line from a search result, a flow card or a review comment has to reopen in
+  the same mode, and `?src=1` is how it travels.
+-->
+<script lang="ts">
+  import { fileHref } from '../../lib/router.svelte';
+
+  interface Props {
+    path: string;
+    line: number | null;
+    /** Which mode is showing. */
+    source: boolean;
+  }
+
+  let { path, line, source }: Props = $props();
+</script>
+
+<nav class="modes" aria-label="File view mode">
+  <a
+    class="mode"
+    class:on={!source}
+    href={fileHref(path, line ? { line } : {})}
+    aria-current={!source ? 'page' : undefined}>Outline</a
+  >
+  <a
+    class="mode"
+    class:on={source}
+    href={fileHref(path, { source: true, ...(line ? { line } : {}) })}
+    aria-current={source ? 'page' : undefined}>Source</a
+  >
+</nav>
+
+<style>
+  .modes {
+    display: flex;
+    gap: 2px;
+  }
+
+  .mode {
+    padding: 3px 10px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-2);
+    font-size: 12.5px;
+    text-decoration: none;
+  }
+
+  .mode:hover {
+    background: var(--press);
+  }
+
+  .mode.on {
+    border-color: var(--ink);
+    color: var(--ink);
+  }
+</style>

+ 56 - 0
ui/src/lib/api.ts

@@ -215,6 +215,48 @@ export interface WireFilePayload {
   dependents: string[];
 }
 
+/* ------------------------------------------------ whole-file source view -- */
+
+/** A reference the resolver never landed: a gutter port with no destination. */
+export interface WireFileOutsideRef {
+  line: number;
+  col: number;
+  name: string;
+  kind: string;
+}
+
+/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
+export interface WireFileCall {
+  /** The symbol making the calls — the file node itself for top-level code. */
+  ownerId: string;
+  ownerLine: number;
+  relation: WireRelation;
+}
+
+export interface WireFileCodePayload {
+  file: {
+    path: string;
+    language: string;
+    size: number;
+    indexedAt: number;
+    contentHash: string;
+    generated: boolean;
+    test: boolean;
+    errors: string[];
+    id: string | null;
+    /** Lines on disk now — the height of the scrolling document. */
+    totalLines: number | null;
+  };
+  drift: boolean;
+  reason?: string;
+  outline: WireList<WireOutlineEntry>;
+  calls: WireList<WireFileCall>;
+  outside: WireList<WireFileOutsideRef>;
+  /** Calls landing on a definition in this same file — the arc diagram's total. */
+  intraFileCalls: number;
+  timing: { elapsedMs: number };
+}
+
 export interface WireBlastScale {
   maxDirect: number;
   maxWithinHops: number;
@@ -534,6 +576,20 @@ export function fetchFile(path: string, signal?: AbortSignal): Promise<WireFileP
   return getJson<WireFilePayload>(`api/file/${encoded}`, signal);
 }
 
+/**
+ * Everything the graph says about the lines of one file: the outline, one row
+ * per (caller, callee) pair with its call-site lines, and the references that
+ * resolved to nothing. The SOURCE is not in here — it pages through
+ * `fetchSource`, so the ports and arcs are complete before any text arrives.
+ */
+export function fetchFileCode(
+  path: string,
+  signal?: AbortSignal
+): Promise<WireFileCodePayload> {
+  const encoded = path.split('/').map(encodeURIComponent).join('/');
+  return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
+}
+
 export function fetchSource(
   file: string,
   from: number,

+ 486 - 0
ui/src/lib/filecode-model.ts

@@ -0,0 +1,486 @@
+/**
+ * The whole-file view's geometry (design spec §3.4, task CG-52).
+ *
+ * The Symbol view *measures* the laid-out DOM to place a callee row beside its
+ * line, because a body of 60 lines can be re-flowed by a fold, a font or a
+ * width. A whole file cannot afford that: `src/mcp/tools.ts` is 6 820 lines, and
+ * asking the browser where each of them ended up — after rendering all of them —
+ * is neither 60 fps nor possible.
+ *
+ * So this screen inverts the contract. **Every line is exactly
+ * {@link CODE_LINE_HEIGHT} tall and its position is arithmetic**, which buys
+ * three things at once: the document's height is known before a byte of source
+ * arrives, only the visible lines are ever in the DOM, and the arcs, the ports
+ * and the rail rows are all functions of a line number rather than of a
+ * measurement. Nothing here touches the DOM.
+ *
+ * The one thing the view still measures is the x of two column edges, for the
+ * connector hairlines — a single ResizeObserver, not a query per line.
+ *
+ * If you ever let a code line grow (a wrapped line, a taller row, an inline
+ * banner), this whole screen silently drifts: lines land at the wrong offset,
+ * arcs point between the wrong ones, rail rows detach from their calls. Change
+ * {@link CODE_LINE_HEIGHT} and the CSS together, or make the list measure.
+ * Same contract as the File view's outline and the Flow strip's cards.
+ */
+
+import type {
+  WireFileCall,
+  WireFileCodePayload,
+  WireFileOutsideRef,
+  WireOutlineEntry,
+} from './api';
+import { lastSegment, relationWords, synthesizedBy, type LineRef } from './symbol-model';
+
+/* ------------------------------------------------------------- constants -- */
+
+/** Height of one source line. Pinned in `FileCodeBlock.svelte`'s CSS. */
+export const CODE_LINE_HEIGHT = 20;
+
+/** Blank space above line 1, so the first line is not flush against the rule. */
+export const CODE_TOP_PAD = 10;
+
+/** Blank space after the last line, so the end of a file can be scrolled to. */
+export const CODE_BOTTOM_PAD = 120;
+
+/** The arc diagram's column, left of the line numbers (design spec §3.4). */
+export const ARC_COLUMN = 56;
+
+/** Callee rail width and row geometry — the Symbol view's numbers, unchanged. */
+export const RAIL_WIDTH = 320;
+export const ROW_HEIGHT = 34;
+export const ROW_GAP = 6;
+
+/** Row height of the sticky navigation rail. Pinned in `FileCodeOutline`'s CSS. */
+export const NAV_ROW_HEIGHT = 24;
+
+/** Lines drawn either side of the viewport, so a flick does not show holes. */
+export const OVERSCAN_LINES = 24;
+
+/**
+ * Source lines fetched in one page.
+ *
+ * Measured on this repo's own TypeScript with the shipped Shiki setup: a warm
+ * grammar tokenises ~7 000 lines/second, so a page plus its lead-in is ~130 ms
+ * of single-threaded server. Bigger pages mean fewer, longer stalls; smaller
+ * ones mean the lead-in dominates. The scroll itself never waits on this —
+ * ports, arcs and rail rows are already drawn from the graph, and the text
+ * arrives behind them.
+ */
+export const PAGE_LINES = 800;
+
+/**
+ * Lines fetched BEFORE a page and thrown away.
+ *
+ * A page that starts in the middle of a block comment, a template literal or a
+ * JSX block does not know it: TextMate state is built by scanning from the top.
+ * Tokenising a run-up and discarding it is what keeps page 6 from rendering a
+ * doc comment as code. The same trick the Flow strip's source windows use, at a
+ * different scale — 150 lines covers every real comment block; a 3 000-line
+ * literal would still be wrong, and would be wrong at any bounded lead-in.
+ */
+export const PAGE_LEAD_IN = 150;
+
+/**
+ * Arcs above which the diagram shows only the focused symbol's.
+ *
+ * Design spec §3.4. Two hundred arcs over six thousand lines is not a picture
+ * of anything — every one of them is a full-height sweep and they overlap into
+ * a grey wash. Past this point the header states the count and the arcs follow
+ * the pointer instead.
+ */
+export const ARC_CROWD_LIMIT = 40;
+
+/** Innermost and outermost bulge of an arc, within {@link ARC_COLUMN}. */
+const ARC_MIN_DEPTH = 7;
+const ARC_MAX_DEPTH = ARC_COLUMN - 8;
+
+/* --------------------------------------------------------------- pixels -- */
+
+/** Top edge of a 1-based file line, in the scrolling document's coordinates. */
+export function lineTop(line: number): number {
+  return CODE_TOP_PAD + (line - 1) * CODE_LINE_HEIGHT;
+}
+
+/** Vertical centre of a line — what an arc, a port and a connector all anchor to. */
+export function lineCentre(line: number): number {
+  return lineTop(line) + CODE_LINE_HEIGHT / 2;
+}
+
+/** Total height of the scrolling document for a file of `totalLines`. */
+export function documentHeight(totalLines: number): number {
+  return CODE_TOP_PAD + Math.max(0, totalLines) * CODE_LINE_HEIGHT + CODE_BOTTOM_PAD;
+}
+
+/**
+ * The 1-based line at a vertical offset in the document.
+ *
+ * Clamped into the file: the top pad is above line 1 and the bottom pad is
+ * below the last, and both should read as the line they are adjacent to.
+ */
+export function lineAtOffset(y: number, totalLines: number): number {
+  if (totalLines <= 0) return 1;
+  const line = Math.floor((y - CODE_TOP_PAD) / CODE_LINE_HEIGHT) + 1;
+  return Math.max(1, Math.min(totalLines, line));
+}
+
+/** The 1-based lines to render for a scroll position, with overscan. */
+export function visibleLines(
+  scrollTop: number,
+  viewport: number,
+  totalLines: number,
+  overscan = OVERSCAN_LINES
+): { first: number; last: number } {
+  if (totalLines <= 0) return { first: 1, last: 0 };
+  const firstRaw = Math.floor((scrollTop - CODE_TOP_PAD) / CODE_LINE_HEIGHT) + 1 - overscan;
+  const count = Math.ceil((viewport || 800) / CODE_LINE_HEIGHT) + overscan * 2;
+  const first = Math.max(1, Math.min(totalLines, firstRaw));
+  return { first, last: Math.max(first - 1, Math.min(totalLines, first + count)) };
+}
+
+/* ---------------------------------------------------------------- pages -- */
+
+export interface SourcePage {
+  /** 0-based page index. */
+  index: number;
+  /** First and last line the page OWNS. */
+  from: number;
+  to: number;
+  /** First line to ASK for — `from` minus the lead-in that gets discarded. */
+  requestFrom: number;
+}
+
+export function pageOf(line: number): number {
+  return Math.floor((line - 1) / PAGE_LINES);
+}
+
+export function pageFor(index: number, totalLines: number): SourcePage {
+  const from = index * PAGE_LINES + 1;
+  return {
+    index,
+    from,
+    to: Math.min(totalLines, from + PAGE_LINES - 1),
+    requestFrom: Math.max(1, from - PAGE_LEAD_IN),
+  };
+}
+
+/** Every page index a rendered line range touches, in reading order. */
+export function pagesForRange(first: number, last: number, totalLines: number): number[] {
+  if (totalLines <= 0 || last < first) return [];
+  const pages: number[] = [];
+  for (let p = pageOf(Math.max(1, first)); p <= pageOf(Math.min(totalLines, last)); p++) {
+    pages.push(p);
+  }
+  return pages;
+}
+
+/* ------------------------------------------------------------- ownership -- */
+
+/**
+ * Which symbol owns a line — the DEEPEST outline entry whose range holds it.
+ *
+ * Entries arrive in source order, so a symbol's descendants follow it and the
+ * last containing entry is the specific answer. Taking the first would land on
+ * the enclosing class, which owns every line of the file equally and would make
+ * hovering anywhere light every arc in it. Same rule the File view's `?hl=`
+ * landing uses.
+ */
+export function ownerAt(outline: readonly WireOutlineEntry[], line: number): string | null {
+  let owner: string | null = null;
+  for (const entry of outline) {
+    if (entry.line > line) break;
+    if (line <= entry.endLine) owner = entry.id;
+  }
+  return owner;
+}
+
+/* ---------------------------------------------------------------- ports -- */
+
+/**
+ * Which identifiers on which lines the graph has something to say about.
+ *
+ * Same shape the Symbol view's code block consumes, so `assignRefs` — the
+ * ladder that makes `this.mutex.withLock(…)` underline `withLock` and not
+ * `this` — is shared rather than re-derived. See `symbol-model.ts`.
+ *
+ * Two file-scale details the single-symbol version does not have:
+ *
+ * * A relation caps the EDGES it ships but never its `lines`, so a group with
+ *   more call sites than edges would lose ports on the overflow. Every line
+ *   without a ref for its target gets one anyway, with no column — the port is
+ *   right, the underline just falls back to the first matching token.
+ * * Unresolved references arrive as one file-wide list rather than as a sample
+ *   per symbol, and are what keeps a line calling `console.log` from showing an
+ *   empty gutter that reads as "nothing happens here".
+ */
+export function buildFileRefs(payload: WireFileCodePayload): Map<number, LineRef[]> {
+  const byLine = new Map<number, LineRef[]>();
+  const add = (line: number, ref: LineRef): void => {
+    const bucket = byLine.get(line);
+    if (bucket) bucket.push(ref);
+    else byLine.set(line, [ref]);
+  };
+
+  for (const call of payload.calls.items) {
+    const relation = call.relation;
+    const ident = lastSegment(relation.node.name);
+    const words = relationWords(relation);
+    const covered = new Set<number>();
+    for (const edge of relation.edges) {
+      if (!edge.line) continue;
+      covered.add(edge.line);
+      add(edge.line, {
+        ident,
+        col: typeof edge.col === 'number' ? edge.col : null,
+        targetId: relation.node.id,
+        uncertain: relation.uncertain,
+        outside: false,
+        title:
+          `${words[0] || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}` +
+          (edge.confidence != null ? ` · confidence ${edge.confidence}` : '') +
+          (edge.resolvedBy ? ` · resolved by ${edge.resolvedBy}` : ''),
+      });
+    }
+    for (const line of relation.lines) {
+      if (covered.has(line)) continue;
+      add(line, {
+        ident,
+        col: null,
+        targetId: relation.node.id,
+        uncertain: relation.uncertain,
+        outside: false,
+        title: `${words[0] || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}`,
+      });
+    }
+  }
+
+  for (const ref of payload.outside.items) add(ref.line, outsideRef(ref));
+  return byLine;
+}
+
+function outsideRef(ref: WireFileOutsideRef): LineRef {
+  return {
+    ident: ref.name,
+    col: ref.col,
+    targetId: null,
+    uncertain: false,
+    outside: true,
+    title: `${ref.name} is not in the index — nothing here resolves it`,
+  };
+}
+
+/* ----------------------------------------------------------------- rail -- */
+
+export interface FileCallRow {
+  /** Stable across re-renders: the pair IS the row. */
+  key: string;
+  /** The symbol in this file that makes the calls. */
+  ownerId: string;
+  call: WireFileCall;
+  /** First call-site line — the height the row wants. */
+  anchor: number | null;
+  lines: number[];
+  words: string[];
+  via: string | null;
+  /** Top edge, in the document's coordinates, after collision resolution. */
+  top: number;
+}
+
+/**
+ * The callee rail: one row per (calling symbol, called symbol) pair, placed.
+ *
+ * The pair is the unit here, not the target. Inside one body a helper called
+ * from three lines is one row with `×3`, and it can sit beside them because
+ * they are a few lines apart. Across a 6 800-line file the same helper called
+ * from two functions a thousand lines apart cannot be one row: a row is
+ * anchored to a line, and there is no line that is both. The server groups it
+ * that way; this places it.
+ *
+ * Placement is the Symbol view's rule, run over the whole file: a row wants the
+ * centre of its first call site, and takes `previous + height + gap` when that
+ * would overlap. Order beats exactness — a rail whose rows jump around relative
+ * to the source stops being a reading of it — and the connector still runs to
+ * the real line, so any displacement is visible rather than silent.
+ */
+export function buildFileCallRows(payload: WireFileCodePayload): FileCallRow[] {
+  const rows: FileCallRow[] = payload.calls.items.map((call) => ({
+    key: `${call.ownerId}>${call.relation.node.id}`,
+    ownerId: call.ownerId,
+    call,
+    anchor: call.relation.lines[0] ?? null,
+    lines: call.relation.lines,
+    words: relationWords(call.relation),
+    via: synthesizedBy(call.relation),
+    top: 0,
+  }));
+
+  // The server already sorts by first call line; sorting again keeps this
+  // function correct on its own, which is what the tests exercise.
+  rows.sort(
+    (a, b) =>
+      (a.anchor ?? Number.MAX_SAFE_INTEGER) - (b.anchor ?? Number.MAX_SAFE_INTEGER) ||
+      a.call.ownerLine - b.call.ownerLine ||
+      a.call.relation.node.name.localeCompare(b.call.relation.node.name)
+  );
+
+  let y = CODE_TOP_PAD;
+  for (const row of rows) {
+    const wanted = row.anchor === null ? y : lineCentre(row.anchor) - ROW_HEIGHT / 2;
+    y = Math.max(wanted, y);
+    row.top = y;
+    y += ROW_HEIGHT + ROW_GAP;
+  }
+  return rows;
+}
+
+/** The rows whose boxes intersect a pixel range. Rows are sorted by `top`. */
+export function rowsInRange(
+  rows: readonly FileCallRow[],
+  from: number,
+  to: number
+): FileCallRow[] {
+  const out: FileCallRow[] = [];
+  for (const row of rows) {
+    if (row.top > to) break;
+    if (row.top + ROW_HEIGHT >= from) out.push(row);
+  }
+  return out;
+}
+
+/** Height the rail needs, so the document never ends above its last row. */
+export function railHeight(rows: readonly FileCallRow[]): number {
+  const last = rows[rows.length - 1];
+  return last ? last.top + ROW_HEIGHT + CODE_BOTTOM_PAD : 0;
+}
+
+/* ----------------------------------------------------------------- arcs -- */
+
+export interface FileArc {
+  key: string;
+  /** The line that makes the call. */
+  fromLine: number;
+  /** The line the callee is defined on. */
+  toLine: number;
+  ownerId: string;
+  targetId: string;
+  targetName: string;
+  uncertain: boolean;
+  synthesized: boolean;
+  /** SVG path, an ellipse half bulging left of the gutter. */
+  d: string;
+  /** Bounding lines, for windowing. */
+  minLine: number;
+  maxLine: number;
+}
+
+/**
+ * One arc per call whose callee is defined in this same file.
+ *
+ * This is the one place a "graph of the file" is legible, and the reason is
+ * that it is not a graph drawing at all: the nodes are already placed, by the
+ * author, in source order, and an arc only has to say which two lines are
+ * connected. Crabviz's layout, with the file's own line numbering as the
+ * vertical axis.
+ *
+ * **Depth is a function of the arc's own span, not of its rank.** Short arcs sit
+ * innermost, as the spec asks, but computing that from a sort position would
+ * make every arc jump sideways the moment the set is filtered to one symbol's.
+ * A log scale against the file's longest span is monotonic in span, stable
+ * under filtering, and keeps the local calls — the ones a reader is following —
+ * legible next to the gutter.
+ */
+export function buildFileArcs(
+  payload: WireFileCodePayload,
+  rows: readonly FileCallRow[]
+): FileArc[] {
+  const path = payload.file.path;
+  const arcs: FileArc[] = [];
+  let maxSpan = 1;
+
+  for (const row of rows) {
+    const target = row.call.relation.node;
+    if (target.file !== path) continue;
+    for (const line of row.lines) {
+      // A recursive call sits ON its own definition line often enough to be
+      // worth skipping: a zero-height arc is a dot, not a drawing.
+      if (line === target.line) continue;
+      const span = Math.abs(target.line - line);
+      if (span > maxSpan) maxSpan = span;
+      arcs.push({
+        key: `${row.key}:${line}`,
+        fromLine: line,
+        toLine: target.line,
+        ownerId: row.ownerId,
+        targetId: target.id,
+        targetName: target.name,
+        uncertain: row.call.relation.uncertain,
+        synthesized: row.via !== null,
+        d: '',
+        minLine: Math.min(line, target.line),
+        maxLine: Math.max(line, target.line),
+      });
+    }
+  }
+
+  const scale = Math.log1p(maxSpan);
+  for (const arc of arcs) {
+    const span = arc.maxLine - arc.minLine;
+    const depth =
+      ARC_MIN_DEPTH +
+      (ARC_MAX_DEPTH - ARC_MIN_DEPTH) * (scale > 0 ? Math.log1p(span) / scale : 0);
+    arc.d = arcPath(arc.fromLine, arc.toLine, depth);
+  }
+
+  // Long arcs first so short ones paint on top of them — the innermost arc is
+  // the one a reader is most likely to be aiming at.
+  arcs.sort((a, b) => b.maxLine - b.minLine - (a.maxLine - a.minLine));
+  return arcs;
+}
+
+/**
+ * Half an ellipse from one line to another, bulging into the arc column.
+ *
+ * Both ends sit on the column's right edge, so `rx` is the whole of the bulge
+ * and `ry` is half the vertical span — the chord is a diameter, which makes the
+ * arc exactly determined and its widest point exactly `depth` to the left.
+ * The sweep flag flips with direction: on a screen whose y grows downward,
+ * sweep 0 runs anticlockwise, which passes left going DOWN and right going up.
+ */
+export function arcPath(fromLine: number, toLine: number, depth: number): string {
+  const y0 = lineCentre(fromLine);
+  const y1 = lineCentre(toLine);
+  const ry = Math.max(1, Math.abs(y1 - y0) / 2);
+  const sweep = y1 > y0 ? 0 : 1;
+  return `M${ARC_COLUMN},${y0} A${depth.toFixed(1)},${ry.toFixed(1)} 0 0 ${sweep} ${ARC_COLUMN},${y1}`;
+}
+
+/**
+ * The arcs to draw: everything when there are few, otherwise the focused
+ * symbol's alone (design spec §3.4).
+ *
+ * "The focused symbol's" is both directions — the calls it makes and the calls
+ * that reach it — because a reader hovering a function is asking about its
+ * neighbourhood, not about its out-edges.
+ */
+export function visibleArcs(
+  arcs: readonly FileArc[],
+  focusId: string | null,
+  crowded: boolean
+): FileArc[] {
+  if (!crowded) return [...arcs];
+  if (!focusId) return [];
+  return arcs.filter((arc) => arc.ownerId === focusId || arc.targetId === focusId);
+}
+
+/** Arcs that reach into a rendered line range. */
+export function arcsInRange(arcs: readonly FileArc[], first: number, last: number): FileArc[] {
+  return arcs.filter((arc) => arc.minLine <= last && arc.maxLine >= first);
+}
+
+/* --------------------------------------------------------------- header -- */
+
+/** `209 calls within this file` / `no calls stay inside this file`. */
+export function arcSummary(count: number): string {
+  if (count === 0) return 'No calls in this file reach another symbol in it.';
+  return `${count} ${count === 1 ? 'call stays' : 'calls stay'} within this file`;
+}

+ 20 - 6
ui/src/lib/router.svelte.ts

@@ -7,7 +7,7 @@
  * Routes (design spec §3.2–§3.6):
  *   #/                     home / nothing selected
  *   #/s/<id>               symbol view      (?hl=<line> highlights a line, ?t=<trail>)
- *   #/file/<path>          file view        (?hl=<line>)
+ *   #/file/<path>          file view        (?hl=<line>, ?src=1 for whole-file source)
  *   #/map                  module map       (?root=&depth=&tests=1)
  *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
  *
@@ -22,7 +22,13 @@
 export type Route =
   | { view: 'home' }
   | { view: 'symbol'; id: string; line: number | null }
-  | { view: 'file'; path: string; line: number | null }
+  | {
+      view: 'file';
+      path: string;
+      line: number | null;
+      /** The whole-file source view rather than the outline (design spec §3.4). */
+      source: boolean;
+    }
   | { view: 'map'; root: string | null; depth: number; tests: boolean }
   | {
       view: 'flow';
@@ -81,7 +87,7 @@ export function parseHash(hash: string): RouterLocation {
   } else if (head === 's' && rest.length > 0) {
     route = { view: 'symbol', id: rest.join('/'), line };
   } else if (head === 'file' && rest.length > 0) {
-    route = { view: 'file', path: rest.join('/'), line };
+    route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
   } else if (head === 'map' && rest.length === 0) {
     // The map's shape travels in the URL like the trail does: a link to
     // "src/vs at depth 2, tests on" has to reopen the same picture.
@@ -120,9 +126,17 @@ export function symbolHref(id: string, opts: { line?: number; trail?: string } =
   return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
 }
 
-export function fileHref(path: string, opts: { line?: number } = {}): string {
-  const query = opts.line ? `?hl=${opts.line}` : '';
-  return `#/file/${encodePath(path)}${query}`;
+export function fileHref(
+  path: string,
+  opts: { line?: number; source?: boolean } = {}
+): string {
+  const params = new URLSearchParams();
+  // `src` before `hl` so the two file URLs a reader shares differ in their
+  // first character after the path, not somewhere in the middle.
+  if (opts.source) params.set('src', '1');
+  if (opts.line) params.set('hl', String(opts.line));
+  const query = params.toString();
+  return `#/file/${encodePath(path)}${query ? `?${query}` : ''}`;
 }
 
 export function mapHref(

+ 645 - 0
ui/src/views/FileCodeView.svelte

@@ -0,0 +1,645 @@
+<!--
+  The whole-file view: the file's own source, top to bottom, with the graph
+  drawn into its margins (design spec §3.4, task CG-52).
+
+  Four columns, all scrolling as one document because all four are readings of
+  the same line numbers:
+
+      outline rail | arcs | source with gutter ports | callee rail
+        (≥ 1400px)   56px                              320px
+
+  The arc column is the piece that only works here. A call whose callee lives in
+  the same file is a relationship between two LINES, and lines already have
+  positions — the author put them there. So the intra-file call graph can be
+  drawn without a layout algorithm, without physics, and without moving a single
+  symbol from where the reader expects it.
+
+  **Everything is placed arithmetically.** One line is 20px, `lineTop(n)` is its
+  offset, and the arcs, the ports, the rail rows and the connectors all derive
+  from that. Nothing measures the DOM except the x of two column edges. That is
+  what makes a 6 820-line file scroll: ninety-odd line elements exist at a time,
+  and the ones the reader has not reached yet still show their port and their
+  place while the text pages in behind them. See `lib/filecode-model.ts`.
+-->
+<script lang="ts">
+  import { untrack } from 'svelte';
+  import CodeArcs from '../components/file/CodeArcs.svelte';
+  import Connectors from '../components/symbol/Connectors.svelte';
+  import FileCodeBlock from '../components/file/FileCodeBlock.svelte';
+  import FileCodeOutline from '../components/file/FileCodeOutline.svelte';
+  import FileCodeRail from '../components/file/FileCodeRail.svelte';
+  import FileModeTabs from '../components/file/FileModeTabs.svelte';
+  import KindGlyph from '../components/KindGlyph.svelte';
+  import {
+    ApiFailure,
+    fetchFileCode,
+    fetchSource,
+    type WireFileCodePayload,
+    type WireNodeRef,
+  } from '../lib/api';
+  import { tokensByLine, type Token } from '../lib/highlight';
+  import { hot } from '../lib/focus.svelte';
+  import { basename, formatBytes, type OutlineEntryRow } from '../lib/file-model';
+  import {
+    ARC_CROWD_LIMIT,
+    arcSummary,
+    arcsInRange,
+    buildFileArcs,
+    buildFileCallRows,
+    buildFileRefs,
+    documentHeight,
+    lineAtOffset,
+    lineCentre,
+    lineTop,
+    ownerAt,
+    pageFor,
+    pagesForRange,
+    railHeight,
+    rowsInRange,
+    visibleArcs,
+    visibleLines,
+    type FileArc,
+    type FileCallRow,
+  } from '../lib/filecode-model';
+  import { plural, synthesizedBy, type Connector, type LineRef } from '../lib/symbol-model';
+  import { walkTo } from '../lib/walk';
+
+  interface Props {
+    path: string;
+    line: number | null;
+  }
+
+  let { path, line }: Props = $props();
+
+  /* ---------------------------------------------------------------- data -- */
+
+  let payload = $state<WireFileCodePayload | null>(null);
+  let failure = $state<ApiFailure | null>(null);
+  let loading = $state(true);
+
+  /**
+   * Classified source by file line, filled in a page at a time.
+   *
+   * A plain Map behind a `$state` box: pages arrive a dozen times over a whole
+   * file, so replacing the map on each one costs nothing measurable and keeps
+   * the reads inside the code block's `$derived` honest.
+   */
+  let tokens = $state(new Map<number, Token[]>());
+  let loadedPages = new Set<number>();
+  let inflightPages = new Set<number>();
+  let pageError = $state<string | null>(null);
+  /** Aborts every page still in flight when the screen moves to another file. */
+  let pageController: AbortController | null = null;
+
+  $effect(() => {
+    const wanted = path;
+    const controller = new AbortController();
+    untrack(() => load(wanted, controller.signal));
+    return () => controller.abort();
+  });
+
+  async function load(file: string, signal: AbortSignal): Promise<void> {
+    loading = true;
+    failure = null;
+    payload = null;
+    tokens = new Map();
+    loadedPages = new Set();
+    inflightPages = new Set();
+    pageError = null;
+    pageController?.abort();
+    pageController = new AbortController();
+    landed = null;
+    hoverLine = null;
+    hoverFocus = null;
+    highlight = null;
+    if (stageEl) stageEl.scrollTop = 0;
+    try {
+      const next = await fetchFileCode(file, signal);
+      if (signal.aborted) return;
+      payload = next;
+    } catch (cause) {
+      if (signal.aborted) return;
+      failure =
+        cause instanceof ApiFailure
+          ? cause
+          : new ApiFailure(0, 'error', cause instanceof Error ? cause.message : String(cause), null);
+    } finally {
+      if (!signal.aborted) loading = false;
+    }
+  }
+
+  /**
+   * Fetch one page of source and merge its tokens in.
+   *
+   * The request reaches back {@link PAGE_LEAD_IN} lines and the lead-in is
+   * thrown away: a page starting inside a block comment cannot tell that it is,
+   * and would render the prose as code. See `filecode-model.ts`.
+   */
+  async function loadPage(index: number): Promise<void> {
+    const file = payload?.file;
+    if (!file || file.totalLines === null) return;
+    if (loadedPages.has(index) || inflightPages.has(index)) return;
+    inflightPages.add(index);
+    const page = pageFor(index, file.totalLines);
+    const signal = pageController?.signal;
+    try {
+      const slice = await fetchSource(file.path, page.requestFrom, page.to, signal);
+      // A different file (or a reload) landed while this was in flight.
+      if (signal?.aborted || payload?.file.path !== file.path) return;
+      if (!slice.lines) {
+        pageError = slice.reason ?? 'Source is not available for this file.';
+        return;
+      }
+      const from = slice.from ?? page.requestFrom;
+      const decoded = tokensByLine(slice.lines, from, slice.highlight);
+      const next = new Map(tokens);
+      for (const [n, value] of decoded) if (n >= page.from) next.set(n, value);
+      tokens = next;
+      loadedPages.add(index);
+    } catch (cause) {
+      if (signal?.aborted) return;
+      pageError = cause instanceof Error ? cause.message : String(cause);
+    } finally {
+      inflightPages.delete(index);
+    }
+  }
+
+  /* -------------------------------------------------------------- models -- */
+
+  let totalLines = $derived(payload?.file.totalLines ?? 0);
+  let refs = $derived(payload ? buildFileRefs(payload) : new Map<number, LineRef[]>());
+  let rows = $derived(payload ? buildFileCallRows(payload) : []);
+  let arcs = $derived(payload ? buildFileArcs(payload, rows) : []);
+  let crowded = $derived(arcs.length > ARC_CROWD_LIMIT);
+
+  let outlineRows = $derived<OutlineEntryRow[]>(
+    (payload?.outline.items ?? []).map((entry) => ({
+      entry,
+      indent: Math.min(entry.depth, 3),
+      dimmed: QUIET_KINDS.has(entry.kind),
+    }))
+  );
+
+  const QUIET_KINDS = new Set(['property', 'field', 'enum_member', 'variable', 'constant']);
+
+  /** Lines a definition starts on → its name, so the name is bold in the body. */
+  let defNames = $derived.by(() => {
+    const map = new Map<number, string>();
+    for (const entry of payload?.outline.items ?? []) {
+      if (!map.has(entry.line)) map.set(entry.line, entry.name);
+    }
+    return map;
+  });
+
+  /** Every symbol this file's calls reach, by id — what a call-site link opens. */
+  let targets = $derived.by(() => {
+    const map = new Map<string, WireNodeRef>();
+    for (const row of rows) map.set(row.call.relation.node.id, row.call.relation.node);
+    return map;
+  });
+
+  /* -------------------------------------------------------------- scroll -- */
+
+  let stageEl = $state<HTMLElement | null>(null);
+  let codeEl = $state<HTMLElement | null>(null);
+  let railEl = $state<HTMLElement | null>(null);
+  let scrollTop = $state(0);
+  let viewport = $state(0);
+  /** x of the code column's right edge and the rail's left, for the hairlines. */
+  let columns = $state({ x0: 0, x1: 0, width: 0 });
+
+  $effect(() => {
+    const el = stageEl;
+    if (!el) return;
+    const read = (): void => {
+      scrollTop = el.scrollTop;
+      viewport = el.clientHeight;
+    };
+    const measure = (): void => {
+      read();
+      const code = codeEl;
+      const rail = railEl;
+      if (!code || !rail) return;
+      columns = {
+        x0: code.offsetLeft + code.offsetWidth - 10,
+        x1: rail.offsetLeft + 14,
+        width: el.scrollWidth,
+      };
+    };
+    measure();
+    el.addEventListener('scroll', read, { passive: true });
+    const observer = new ResizeObserver(measure);
+    observer.observe(el);
+    return () => {
+      el.removeEventListener('scroll', read);
+      observer.disconnect();
+    };
+  });
+
+  let visible = $derived(visibleLines(scrollTop, viewport, totalLines));
+
+  // Whatever is on screen has to have its source. The pages are fetched here
+  // rather than inside the block so a fast scroll past a page does not leave a
+  // request for it half-applied to a screen that has moved on.
+  $effect(() => {
+    if (!payload || payload.drift) return;
+    const wanted = pagesForRange(visible.first, visible.last, totalLines);
+    untrack(() => {
+      for (const index of wanted) void loadPage(index);
+    });
+  });
+
+  /* --------------------------------------------------------------- focus -- */
+
+  let hoverLine = $state<number | null>(null);
+  /** Set by a rail row: its row names a caller the code lines cannot. */
+  let hoverFocus = $state<string | null>(null);
+  let highlight = $state<number | null>(null);
+
+  /** The symbol the reader is inside, from the scroll position. */
+  let currentId = $derived(
+    ownerAt(payload?.outline.items ?? [], lineAtOffset(scrollTop + 8, totalLines))
+  );
+
+  /**
+   * The symbol whose arcs are drawn once there are too many to draw all of them.
+   *
+   * Pointer first, scroll position last. There is deliberately no *pinned*
+   * symbol: clicking an arc scrolls to its callee, so the callee becomes the
+   * symbol the reader is inside and its arcs follow from that. A pin would be a
+   * second, invisible piece of state answering the same question — and would go
+   * stale the moment the reader scrolled somewhere else.
+   */
+  let focusId = $derived.by(() => {
+    if (hoverFocus !== null) return hoverFocus;
+    const outline = payload?.outline.items ?? [];
+    if (hoverLine !== null) return ownerAt(outline, hoverLine);
+    return currentId;
+  });
+
+  /* ------------------------------------------------------------- drawing -- */
+
+  let shownArcs = $derived(visibleArcs(arcs, focusId, crowded));
+  let windowArcs = $derived(arcsInRange(shownArcs, visible.first, visible.last));
+
+  let windowRows = $derived(
+    rowsInRange(rows, scrollTop - viewport, scrollTop + viewport * 2)
+  );
+
+  /**
+   * One hairline per call site, from the gutter port to its rail row.
+   *
+   * Both coordinates are the document's, so they hold at any scroll position —
+   * and only the rows on screen are drawn, which is what keeps eight hundred
+   * call sites from being eight hundred paths.
+   */
+  let connectors = $derived.by<Connector[]>(() => {
+    const { x0, x1 } = columns;
+    if (x1 <= x0) return [];
+    const cx = (x0 + x1) / 2;
+    const out: Connector[] = [];
+    for (const row of windowRows) {
+      const ry = row.top + 17;
+      const via = synthesizedBy(row.call.relation);
+      for (const callLine of row.lines) {
+        const ly = lineCentre(callLine);
+        out.push({
+          d: `M${x0},${ly} C${cx},${ly} ${cx},${ry} ${x1},${ry}`,
+          targetId: row.call.relation.node.id,
+          uncertain: row.call.relation.uncertain,
+          heuristic: via !== null,
+          origin: false,
+        });
+      }
+    }
+    return out;
+  });
+
+  let docHeight = $derived(Math.max(documentHeight(totalLines), railHeight(rows)));
+
+  /* ------------------------------------------------------------ movement -- */
+
+  /** Scroll a line into the upper third, where a reader looks for it. */
+  function goToLine(target: number): void {
+    const el = stageEl;
+    if (!el) return;
+    el.scrollTop = Math.max(0, lineTop(target) - el.clientHeight / 3);
+    highlight = target;
+  }
+
+  /**
+   * Clicking an arc focuses its callee: the source moves to the definition and
+   * the callee lights everywhere it appears — its rail row, its call sites,
+   * its other arcs. The call line the reader came from stays the thing that put
+   * it on screen, which is why the pin is the TARGET and not the owner.
+   */
+  function followArc(arc: FileArc): void {
+    hot.set(arc.targetId);
+    goToLine(arc.toLine);
+  }
+
+  /** A call site in the body, or a rail row: open the callee as a symbol. */
+  function openNode(node: WireNodeRef): void {
+    // Same file, same screen — jump rather than leave.
+    if (node.file === payload?.file.path) {
+      hot.set(node.id);
+      goToLine(node.line);
+      return;
+    }
+    walkTo({ id: node.id, name: node.name, kind: node.kind }, 'start');
+  }
+
+  function followRef(ref: LineRef): void {
+    const node = ref.targetId ? targets.get(ref.targetId) : undefined;
+    if (node) openNode(node);
+  }
+
+  /** A rail row names the CALLER, which is the symbol its arcs belong to. */
+  function onhoverRow(row: FileCallRow | null): void {
+    hoverFocus = row ? row.ownerId : null;
+  }
+
+  /* --------------------------------------------------- arriving at a line -- */
+
+  let landed: string | null = null;
+  $effect(() => {
+    const key = line === null ? null : `${path}:${line}`;
+    if (!key || !payload || landed === key || !stageEl || totalLines === 0) return;
+    landed = key;
+    goToLine(line as number);
+  });
+</script>
+
+{#if failure}
+  <div class="scroll">
+    <div class="emptystate">
+      <h2>{failure.code === 'not-found' ? 'Not in the index' : 'Could not load this file'}</h2>
+      <p class="mono">{path}</p>
+      <p>{failure.message}</p>
+      {#if failure.guidance}<p class="dim">{failure.guidance}</p>{/if}
+    </div>
+  </div>
+{:else if loading || !payload}
+  <div class="scroll">
+    <div class="emptystate"><p class="dim">Loading…</p></div>
+  </div>
+{:else}
+  <div class="codeview" class:wide={outlineRows.length > 0}>
+    {#if outlineRows.length > 0}
+      <aside class="nav">
+        <FileCodeOutline
+          rows={outlineRows}
+          total={payload.outline.total}
+          truncated={payload.outline.truncated}
+          {currentId}
+          ongo={(at, id) => {
+            hot.set(id);
+            goToLine(at);
+          }}
+        />
+      </aside>
+    {/if}
+
+    <section class="main">
+      <header class="band">
+        <div class="card-h">
+          <KindGlyph kind="file" />
+          <h1>{basename(payload.file.path)}</h1>
+          <span class="kindword">
+            {payload.file.language} · {formatBytes(payload.file.size)} ·
+            {payload.file.totalLines === null
+              ? 'length unknown'
+              : plural(payload.file.totalLines, 'line')} ·
+            {plural(payload.outline.total, 'symbol')}
+          </span>
+          <span class="loc">{payload.file.path}</span>
+          <div class="spacer"></div>
+          <FileModeTabs path={payload.file.path} {line} source={true} />
+        </div>
+
+        <div class="toolbar">
+          <span class="arcnote">
+            {arcSummary(payload.intraFileCalls)}{#if crowded}{' '}<span class="dim"
+                >— showing the ones the symbol under the pointer takes part in</span
+              >{/if}
+          </span>
+          <span
+            class="railnote"
+            title="One row per pair: a symbol in this file, and a symbol it reaches."
+          >
+            Calls <span class="n">{payload.calls.total}</span>{#if payload.calls.truncated}<span
+                class="dim"> · showing {payload.calls.shown}</span
+              >{/if}{#if payload.outside.total > 0}{' '}<span class="dim"
+                >· {plural(payload.outside.total, 'reference')} outside the index</span
+              >{/if}
+          </span>
+        </div>
+
+        {#if payload.drift}
+          <div class="drift">
+            {payload.reason ??
+              'This file changed on disk after the last index sync.'} The source is not shown, because
+            the line numbers the graph holds no longer match it. Run <code>codegraph sync</code> to bring
+            them up to date.
+          </div>
+        {:else if payload.file.totalLines === null}
+          <div class="drift">
+            {payload.reason ?? 'This file could not be read from disk.'}
+          </div>
+        {:else if pageError}
+          <div class="drift">{pageError}</div>
+        {/if}
+      </header>
+
+      {#if !payload.drift && payload.file.totalLines !== null}
+        <div
+          class="stage"
+          bind:this={stageEl}
+          onmouseleave={() => {
+            hoverLine = null;
+          }}
+          role="presentation"
+        >
+          <div class="stage-inner" style:height={`${docHeight}px`}>
+            <div class="arccol">
+              <CodeArcs arcs={windowArcs} height={docHeight} {hoverLine} onfollow={followArc} />
+            </div>
+
+            <div class="codecol" bind:this={codeEl}>
+              <FileCodeBlock
+                first={visible.first}
+                last={visible.last}
+                tokensFor={(n) => tokens.get(n) ?? null}
+                {refs}
+                {defNames}
+                {highlight}
+                onfollow={followRef}
+                onhoverline={(n) => {
+                  hoverLine = n;
+                }}
+              />
+            </div>
+
+            <aside class="rail" bind:this={railEl} aria-label="Calls">
+              <FileCodeRail
+                rows={windowRows}
+                focalFile={payload.file.path}
+                {focusId}
+                onopen={openNode}
+                onhover={onhoverRow}
+              />
+            </aside>
+
+            <Connectors {connectors} width={columns.width} height={docHeight} />
+          </div>
+        </div>
+      {/if}
+    </section>
+  </div>
+{/if}
+
+<style>
+  .scroll {
+    height: 100%;
+    overflow: auto;
+  }
+
+  .codeview {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    height: 100%;
+    min-height: 0;
+  }
+
+  /* The navigation rail is a luxury, not the screen: below 1400px the code and
+     its two margins take the whole width rather than all three squeezing. */
+  @media (min-width: 1400px) {
+    .codeview.wide {
+      grid-template-columns: 240px minmax(0, 1fr);
+    }
+  }
+
+  .nav {
+    display: none;
+    min-height: 0;
+  }
+
+  @media (min-width: 1400px) {
+    .codeview.wide .nav {
+      display: block;
+    }
+  }
+
+  .main {
+    display: grid;
+    grid-template-rows: auto minmax(0, 1fr);
+    min-width: 0;
+    min-height: 0;
+  }
+
+  /* Outside the scroller on purpose: a header inside it would put every line at
+     `headerHeight + (n - 1) * 20`, and the header's height is a measurement. */
+  .band {
+    padding: 14px 22px 8px;
+    border-bottom: 1px solid var(--rule);
+    background: var(--paper);
+  }
+
+  .card-h {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 6px 12px;
+  }
+
+  .card-h h1 {
+    margin: 0;
+    font: 600 20px/1.2 var(--mono);
+    letter-spacing: -0.01em;
+  }
+
+  .spacer {
+    flex: 1 1 auto;
+  }
+
+  .kindword {
+    color: var(--ink-3);
+    font-size: 12.5px;
+  }
+
+  .loc {
+    color: var(--ink-2);
+    font: 11.5px var(--mono);
+  }
+
+  .toolbar {
+    display: flex;
+    justify-content: space-between;
+    gap: 16px;
+    margin-top: 8px;
+    color: var(--ink-2);
+    font-size: 11.5px;
+  }
+
+  .railnote .n {
+    color: var(--ink-3);
+  }
+
+  .arcnote,
+  .railnote {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .drift {
+    margin-top: 10px;
+    padding: 8px 12px;
+    border: 1px solid var(--amber);
+    background: var(--amber-soft);
+    color: var(--amber);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+
+  .drift code {
+    font: 12px var(--mono);
+  }
+
+  .stage {
+    position: relative;
+    min-height: 0;
+    overflow: auto;
+  }
+
+  /* The positioning context every arithmetic coordinate is expressed in: line
+     tops, arc endpoints, rail rows and the connector overlay share this origin. */
+  .stage-inner {
+    position: relative;
+    display: grid;
+    grid-template-columns: 56px minmax(420px, 1fr) 320px;
+    min-width: 100%;
+  }
+
+  .arccol {
+    position: relative;
+    border-right: 1px solid var(--rule-faint);
+  }
+
+  .codecol {
+    position: relative;
+    min-width: 0;
+    padding-left: 4px;
+  }
+
+  .rail {
+    position: relative;
+    border-left: 1px solid var(--rule-faint);
+  }
+
+  @media (max-width: 1100px) {
+    .stage-inner {
+      grid-template-columns: 56px minmax(320px, 1fr) 260px;
+    }
+  }
+</style>

+ 10 - 2
ui/src/views/FileView.svelte

@@ -11,13 +11,15 @@
   — every cross-file edge, not just resolved import statements. See
   `lib/file-model.ts` for why that distinction is the whole point of the rails.
 
-  Whole-file source with gutter ports is phase 2 (CG-52); this screen is the
-  outline, and the row that opens a symbol is the way into the code.
+  The whole file's source, with the same gutter ports and an arc diagram for the
+  calls that stay inside it, is the other reading of this screen — `?src=1`,
+  `FileCodeView.svelte` (CG-52). The tabs in the header switch between them.
 -->
 <script lang="ts">
   import { tick, untrack } from 'svelte';
   import FileOutline from '../components/file/FileOutline.svelte';
   import FileRail from '../components/file/FileRail.svelte';
+  import FileModeTabs from '../components/file/FileModeTabs.svelte';
   import KindGlyph from '../components/KindGlyph.svelte';
   import { ApiFailure, fetchFile, type WireFilePayload, type WireNodeRef } from '../lib/api';
   import {
@@ -253,6 +255,8 @@
         <h1>{basename(payload.file.path)}</h1>
         <span class="kindword">{fileMetaLine(payload)}</span>
         <span class="loc">{payload.file.path}</span>
+        <div class="spacer"></div>
+        <FileModeTabs path={payload.file.path} {line} source={false} />
       </div>
 
       <div class="badges">
@@ -350,6 +354,10 @@
     letter-spacing: -0.01em;
   }
 
+  .spacer {
+    flex: 1 1 auto;
+  }
+
   .kindword {
     color: var(--ink-3);
     font-size: 12.5px;