Parcourir la source

feat(ui): the File view — outline in source order between two dependency rails (CG-46)

Clicking a file path now opens the file itself: what reaches into it, its
symbols in source order, and what it reaches.

The two rails count DEPENDENCIES, not import statements. The prototype drew
`imports` edges; on this repo `src/graph/traversal.ts` imports two files and
depends on four, because it reaches the LRU cache through a call no import
names. A rail headed "Imports 2" would be quietly wrong about what changing the
file would touch, which is the only question the screen answers — so the rails
read `getFileDependencies` / `getFileDependents` and merge the import rows in
for the symbol names. Imports that resolved to nothing indexed keep their own
section rather than vanishing.

The outline is windowed above 250 rows against a fixed 28px row: this repo's
own fixtures hold a 1,681-symbol `.d.ts`, and paging it would hide the one
thing an outline is for. `src/mcp/tools.ts` draws its 135 rows whole.

`/api/file` gains `topLevel.calls` — module-level calls out of the file node —
so a file that RUNS something offers the badge that opens it as a symbol, the
only place code belonging to no symbol can be read.

File results in the search palette and the entry-point list now land here
rather than on the file node's Symbol view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry il y a 1 semaine
Parent
commit
58dad12f89

+ 304 - 0
__tests__/ui-file-model.test.ts

@@ -0,0 +1,304 @@
+/**
+ * The File view's models, without a browser (CG-46).
+ *
+ * The decision under test throughout is the rails' source of truth: they are
+ * built from `dependencies` / `dependents` — the engine's own
+ * `getFileDependencies` / `getFileDependents` — and merely *decorated* with
+ * the `imports` rows. Getting that backwards is not a cosmetic bug: it silently
+ * understates what a change to the file would reach, which is the only reason
+ * the screen exists.
+ *
+ * The geometry-free sibling of `ui-symbol-model.test.ts` and
+ * `ui-search-model.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildFileOutline,
+  buildFileRail,
+  fileMetaLine,
+  formatBytes,
+  looksLikeTest,
+  OUTLINE_ROW_HEIGHT,
+  OUTLINE_VIRTUAL_THRESHOLD,
+} from '../ui/src/lib/file-model';
+import type { WireFilePayload, WireImportRow, WireOutlineEntry } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function importRow(over: Partial<WireImportRow> = {}): WireImportRow {
+  const symbols = over.symbols ?? [
+    { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 },
+  ];
+  return {
+    file: over.file ?? 'src/db/queries.ts',
+    test: over.test ?? false,
+    symbols,
+    symbolCount: over.symbolCount ?? symbols.length,
+  };
+}
+
+function entry(over: Partial<WireOutlineEntry> = {}): WireOutlineEntry {
+  return {
+    id: over.id ?? 'method:x',
+    kind: 'method',
+    name: 'traverseBFS',
+    qualifiedName: 'GraphTraverser.traverseBFS',
+    file: 'src/graph/traversal.ts',
+    line: 48,
+    endLine: 150,
+    language: 'typescript',
+    test: false,
+    parentId: 'class:GraphTraverser',
+    depth: 1,
+    fanIn: 3,
+    fanOut: 7,
+    ...over,
+  } as WireOutlineEntry;
+}
+
+function payload(over: Partial<WireFilePayload> = {}): WireFilePayload {
+  return {
+    file: {
+      path: 'src/graph/traversal.ts',
+      language: 'typescript',
+      size: 24216,
+      modifiedAt: 1,
+      indexedAt: 2,
+      contentHash: 'abc',
+      nodeCount: 26,
+      generated: false,
+      test: false,
+      errors: [],
+      id: 'file:src/graph/traversal.ts',
+    },
+    topLevel: { calls: 0 },
+    drift: false,
+    outline: { total: 0, shown: 0, truncated: false, items: [] },
+    imports: { total: 0, shown: 0, truncated: false, items: [] },
+    importedBy: { total: 0, shown: 0, truncated: false, items: [] },
+    unresolvedImports: [],
+    dependencies: [],
+    dependents: [],
+    ...over,
+  } as WireFilePayload;
+}
+
+/* ----------------------------------------------------------------- rail -- */
+
+describe('the import rails', () => {
+  it('counts every dependency, not just the ones an import statement named', () => {
+    // The real shape on this repo: traversal.ts imports two files and depends
+    // on four — it reaches the LRU cache through a call with no import.
+    const rail = buildFileRail(
+      [
+        'src/db/queries.ts',
+        'src/resolution/lru-cache.ts',
+        'src/types.ts',
+        'scripts/agent-eval/probe.mjs',
+      ],
+      [importRow({ file: 'src/db/queries.ts' }), importRow({ file: 'src/types.ts' })]
+    );
+
+    expect(rail.total).toBe(4);
+    expect(rail.rows).toHaveLength(4);
+    expect(rail.rows.filter((r) => r.imported).map((r) => r.path)).toEqual([
+      'src/db/queries.ts',
+      'src/types.ts',
+    ]);
+    expect(rail.rows.find((r) => r.path === 'src/resolution/lru-cache.ts')?.imported).toBe(false);
+  });
+
+  it('names the symbols an import row carries, on the row for that file', () => {
+    const rail = buildFileRail(
+      ['src/db/queries.ts'],
+      [
+        importRow({
+          symbols: [
+            { id: 'class:Q', name: 'QueryBuilder', kind: 'class', line: 219 },
+            { id: 'iface:R', name: 'Row', kind: 'interface', line: 12 },
+          ],
+        }),
+      ]
+    );
+    expect(rail.rows[0]?.symbols.map((s) => s.name)).toEqual(['QueryBuilder', 'Row']);
+    expect(rail.rows[0]?.symbolCount).toBe(2);
+  });
+
+  it('does not count a file node as a named symbol', () => {
+    // An `importedBy` edge's far end is the importing file's own file node, so
+    // its "symbols" repeat the path already in the row. A `1` there would be a
+    // count of nothing.
+    const rail = buildFileRail(
+      ['src/index.ts'],
+      [
+        importRow({
+          file: 'src/index.ts',
+          symbols: [{ id: 'file:src/index.ts', name: 'index.ts', kind: 'file', line: 1 }],
+        }),
+      ]
+    );
+    expect(rail.rows[0]?.symbolCount).toBe(0);
+    expect(rail.rows[0]?.imported).toBe(true);
+  });
+
+  it('sorts production files before tests, each alphabetically', () => {
+    const rail = buildFileRail(
+      ['src/z.ts', '__tests__/graph.test.ts', 'src/a.ts', '__tests__/a.test.ts'],
+      []
+    );
+    expect(rail.rows.map((r) => r.path)).toEqual([
+      'src/a.ts',
+      'src/z.ts',
+      '__tests__/a.test.ts',
+      '__tests__/graph.test.ts',
+    ]);
+    expect(rail.testCount).toBe(2);
+  });
+
+  it('trusts the server about what is a test, and falls back to the path', () => {
+    const rail = buildFileRail(
+      ['src/looks-normal.ts', 'src/other.ts'],
+      // The server can see more than a path; a row it marks wins.
+      [importRow({ file: 'src/looks-normal.ts', test: true })]
+    );
+    expect(rail.rows[0]?.path).toBe('src/other.ts');
+    expect(rail.rows[1]?.test).toBe(true);
+  });
+
+  it('de-duplicates a file the engine listed twice', () => {
+    const rail = buildFileRail(['src/a.ts', 'src/a.ts'], []);
+    expect(rail.rows).toHaveLength(1);
+    expect(rail.total).toBe(1);
+  });
+
+  it('folds unresolved imports by name, keeping every line', () => {
+    const rail = buildFileRail(
+      [],
+      [],
+      [
+        { name: 'node:fs', line: 12 },
+        { name: 'react', line: 3 },
+        { name: 'node:fs', line: 4 },
+      ]
+    );
+    expect(rail.outside).toEqual([
+      { name: 'node:fs', lines: [4, 12] },
+      { name: 'react', lines: [3] },
+    ]);
+    // Outside-index rows never inflate the dependency count.
+    expect(rail.total).toBe(0);
+  });
+});
+
+describe('looksLikeTest', () => {
+  it('recognises the shapes an unnamed dependency can arrive in', () => {
+    expect(looksLikeTest('__tests__/graph.test.ts')).toBe(true);
+    expect(looksLikeTest('src/service.spec.ts')).toBe(true);
+    expect(looksLikeTest('test/helper.go')).toBe(true);
+    expect(looksLikeTest('__tests__/fixtures/app/main.ts')).toBe(true);
+  });
+
+  it('errs towards production — misfiling a real file is the worse mistake', () => {
+    expect(looksLikeTest('src/latest.ts')).toBe(false);
+    expect(looksLikeTest('src/protest/index.ts')).toBe(false);
+    expect(looksLikeTest('src/testing-library.ts')).toBe(false);
+  });
+});
+
+/* -------------------------------------------------------------- outline -- */
+
+describe('the file outline', () => {
+  it('keeps the server order and indents by depth', () => {
+    const rows = buildFileOutline(
+      payload({
+        outline: {
+          total: 3,
+          shown: 3,
+          truncated: false,
+          items: [
+            entry({ id: 'class:C', kind: 'class', name: 'GraphTraverser', depth: 0, line: 34 }),
+            entry({ id: 'method:m', depth: 1, line: 48 }),
+            entry({ id: 'prop:p', kind: 'property', name: 'queries', depth: 1, line: 35 }),
+          ],
+        },
+      })
+    );
+    expect(rows.map((r) => r.entry.id)).toEqual(['class:C', 'method:m', 'prop:p']);
+    expect(rows.map((r) => r.indent)).toEqual([0, 1, 1]);
+  });
+
+  it('dims data rather than behaviour', () => {
+    const rows = buildFileOutline(
+      payload({
+        outline: {
+          total: 4,
+          shown: 4,
+          truncated: false,
+          items: [
+            entry({ id: 'a', kind: 'property' }),
+            entry({ id: 'b', kind: 'enum_member' }),
+            entry({ id: 'c', kind: 'method' }),
+            entry({ id: 'd', kind: 'class' }),
+          ],
+        },
+      })
+    );
+    expect(rows.map((r) => r.dimmed)).toEqual([true, true, false, false]);
+  });
+
+  it('clamps the indent so a deeply nested closure stays in its column', () => {
+    const rows = buildFileOutline(
+      payload({
+        outline: {
+          total: 1,
+          shown: 1,
+          truncated: false,
+          items: [entry({ depth: 9 })],
+        },
+      })
+    );
+    expect(rows[0]?.indent).toBe(3);
+  });
+
+  it('windows past a threshold that leaves ordinary files alone', () => {
+    // 135 symbols in this repo's biggest hand-written file (src/mcp/tools.ts);
+    // 1,681 in the generated fixture that motivated the window.
+    expect(OUTLINE_VIRTUAL_THRESHOLD).toBeGreaterThan(135);
+    expect(OUTLINE_ROW_HEIGHT).toBeGreaterThan(0);
+  });
+});
+
+/* --------------------------------------------------------------- header -- */
+
+describe('the header line', () => {
+  it('counts the outline, not the file record', () => {
+    // nodeCount includes the file node and its import declarations; neither is
+    // a row, and a header disagreeing with the list under it is unresolvable.
+    const line = fileMetaLine(
+      payload({
+        file: { ...payload().file, nodeCount: 26 },
+        outline: { total: 23, shown: 23, truncated: false, items: [] },
+      })
+    );
+    expect(line).toBe('typescript · 23.6 KB · 23 symbols');
+  });
+
+  it('tags a generated file and a test file', () => {
+    const line = fileMetaLine(
+      payload({
+        file: { ...payload().file, generated: true, test: true, size: 1024 },
+        outline: { total: 1, shown: 1, truncated: false, items: [] },
+      })
+    );
+    expect(line).toBe('typescript · 1.0 KB · 1 symbol · generated · test');
+  });
+
+  it('formats sizes for scale, never for accounting', () => {
+    expect(formatBytes(0)).toBe('0 B');
+    expect(formatBytes(999)).toBe('999 B');
+    expect(formatBytes(24216)).toBe('23.6 KB');
+    expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
+    expect(formatBytes(Number.NaN)).toBe('—');
+  });
+});

+ 11 - 0
__tests__/ui-server-api.test.ts

@@ -845,6 +845,17 @@ describe('GET /api/file/<path>', () => {
     expect(body.dependencies).toContain('src/types.ts');
   });
 
+  it('says whether the file runs anything at its top level', async () => {
+    // `src/main.ts` instantiates a Service and calls two functions outside
+    // every definition — code no outline row can show, because it belongs to
+    // no symbol. `src/cache.ts` only defines things.
+    const main = await getJson('/api/file/src/main.ts');
+    expect(main.topLevel.calls).toBeGreaterThanOrEqual(2);
+
+    const cache = await getJson('/api/file/src/cache.ts');
+    expect(cache.topLevel.calls).toBe(0);
+  });
+
   it('404s a file that is not in the index and refuses one outside the project', async () => {
     const missing = await getStatusAndJson('/api/file/src/nope.ts');
     expect(missing.status).toBe(404);

+ 16 - 0
docs/design/codegraph-ui-design-spec.md

@@ -143,6 +143,22 @@ Grid **300px | minmax(480px,1fr) | 300px**: Imported by · outline (source order
 File rows 12px mono, 5px 14px padding, `--rule-faint` separators; files outside the index in `--ink-3`, not clickable.
 Header: file glyph, basename as h1, `lang · KB · N symbols · generated`, full path.
 
+**As built (phase 1, CG-46).** The two rails count **dependencies**, not import statements —
+`getFileDependencies` / `getFileDependents`, every cross-file edge except `contains`. The prototype
+drew `imports` edges alone, and on this repo that understates the answer: `src/graph/traversal.ts`
+imports two files and depends on four (it reaches `src/resolution/lru-cache.ts` through a call no
+import names). The import rows are still merged in — they carry the symbol NAMES, shown as a count
+on the row and in full in its tooltip. Rows sort production-first then alphabetically, tests last.
+Imports that resolved to nothing indexed are listed under **Outside the index**, in `--ink-3` and
+not clickable, so a file importing `react` and `fs` does not read as having one dependency.
+The header's `N symbols` is the OUTLINE's total, not the file record's node count (which includes
+the file node and its import declarations). A file that runs code at its top level — an edge out of
+the file node — carries a badge ("Runs N calls at the top level — see what it calls") that focuses
+the file node, the only place that code can be read. Outline rows are a fixed 28px and the list is
+windowed above 250 rows (this repo's own fixtures hold a 1,681-symbol `.d.ts`); the two constants
+live together in `ui/src/lib/file-model.ts`. Keyboard: ↑/↓ within a pane, ←/→ across the three
+panes, Enter follows; `?hl=<line>` selects the DEEPEST outline row whose range holds the line.
+
 ### 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`

+ 18 - 0
src/ui-server/api/file.ts

@@ -127,6 +127,16 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
   // records a file-level import.
   const unresolvedImports = fileNode ? unresolvedImportsOf(cg, fileNode.id) : [];
 
+  // Whether the file RUNS anything at its top level. Extraction records a
+  // statement outside any definition as an edge out of the FILE node, so a
+  // module that only defines things has none and a CLI entry point has many —
+  // the same signal `/api/entrypoints` ranks on. It is worth a line on this
+  // screen because the outline cannot show it: top-level code belongs to no
+  // symbol, so the only way to read it is to open the file node itself.
+  const topLevelEdges = fileNode
+    ? cg.getOutgoingEdgesFrom([fileNode.id], ['calls', 'instantiates'])
+    : [];
+
   return {
     file: {
       path: toPosixPath(storedPath),
@@ -142,6 +152,14 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
       /** The file node itself, so the viewer can navigate to it as a symbol. */
       id: fileNode?.id ?? null,
     },
+    /**
+     * Calls made at the top level of the file, outside every definition.
+     * Counted as distinct call SITES — `(target, line, column)` — so a call
+     * two resolvers both recorded is one thing to read, not two.
+     */
+    topLevel: {
+      calls: new Set(topLevelEdges.map((e) => `${e.target}:${e.line ?? 0}:${e.column ?? 0}`)).size,
+    },
     /** 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),

+ 8 - 1
ui/src/components/TopBar.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { router, mapHref, flowHref, symbolHref } from '../lib/router.svelte';
+  import { router, mapHref, flowHref, symbolHref, fileHref, navigate } from '../lib/router.svelte';
   import { trail } from '../lib/trail.svelte';
   import { palette } from '../lib/palette.svelte';
   import SearchPalette from './SearchPalette.svelte';
@@ -46,6 +46,13 @@
     if (!id) return;
     palette.reset();
     input?.blur();
+    // A file result opens the File view, not the file node's Symbol view: the
+    // outline is there either way, and only the File view carries the import
+    // rails. (CG-45 routed these at the Symbol view because #/file was a stub.)
+    if (item.type === 'symbol' && item.node.kind === 'file') {
+      navigate(fileHref(item.node.file));
+      return;
+    }
     walkTo(
       item.type === 'route'
         ? { id, name: item.handler, kind: null }

+ 224 - 0
ui/src/components/file/FileOutline.svelte

@@ -0,0 +1,224 @@
+<!--
+  The file's symbols in source order (design spec §3.4).
+
+  Same row geometry as the Symbol view's members outline — `16px | name | 1fr |
+  counts` — because they answer the same question at two scales, and a reader
+  who has learned one should not have to learn the other. What differs is the
+  right column: a file outline prints the LINE number as well as the edge
+  counts, since source order is the only ordering here and the line is how a
+  row is found in an editor.
+
+  Long files are windowed rather than paged. `worker-configuration.d.ts` in
+  this repo's own fixtures holds 1,681 symbols; rendering them all costs about
+  a second of layout on every scroll, and paging would hide exactly the thing
+  the outline exists to give — one uninterrupted read of the file's shape.
+  Rows are a fixed height (pinned in the CSS below, and asserted by the
+  `OUTLINE_ROW_HEIGHT` constant) so the window's arithmetic stays exact.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import type { WireNodeRef } from '../../lib/api';
+  import {
+    OUTLINE_ROW_HEIGHT,
+    OUTLINE_VIRTUAL_THRESHOLD,
+    type OutlineEntryRow,
+  } from '../../lib/file-model';
+
+  interface Props {
+    rows: OutlineEntryRow[];
+    total: number;
+    truncated: boolean;
+    /** The scroll container the rows live inside — the view's centre column. */
+    scroller: HTMLElement | null;
+    /** Index of the keyboard's position, or -1. */
+    selected?: number;
+    onopen: (node: WireNodeRef) => void;
+    onhover?: (index: number) => void;
+  }
+
+  let { rows, total, truncated, scroller, selected = -1, onopen, onhover }: Props = $props();
+
+  let listEl = $state<HTMLDivElement | null>(null);
+  let scrollTop = $state(0);
+  let viewport = $state(0);
+
+  let virtual = $derived(rows.length > OUTLINE_VIRTUAL_THRESHOLD);
+
+  /**
+   * The slice to draw, plus the spacer heights that keep the scrollbar honest.
+   *
+   * The offset is measured against the SCROLLER, not the list, because the
+   * header above the outline scrolls with it: `listEl.offsetTop` is where the
+   * first row starts inside that coordinate space. An overscan of eight rows
+   * covers a fast flick between two measurements.
+   */
+  let window_ = $derived.by(() => {
+    if (!virtual) return { start: 0, end: rows.length, before: 0, after: 0 };
+    const top = listEl ? listEl.offsetTop : 0;
+    const first = Math.floor((scrollTop - top) / OUTLINE_ROW_HEIGHT) - 8;
+    const count = Math.ceil((viewport || 800) / OUTLINE_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 * OUTLINE_ROW_HEIGHT,
+      after: (rows.length - end) * OUTLINE_ROW_HEIGHT,
+    };
+  });
+
+  // Keep the row the keyboard just moved to on screen. Rows are a fixed height
+  // in both modes, so the arithmetic is the same — and it has to be arithmetic
+  // rather than `scrollIntoView`, because a windowed row far outside the drawn
+  // slice has no element to scroll to.
+  $effect(() => {
+    if (selected < 0 || !scroller || !listEl) return;
+    const rowTop = listEl.offsetTop + selected * OUTLINE_ROW_HEIGHT;
+    const rowBottom = rowTop + OUTLINE_ROW_HEIGHT;
+    if (rowTop < scroller.scrollTop) scroller.scrollTop = rowTop - OUTLINE_ROW_HEIGHT;
+    else if (rowBottom > scroller.scrollTop + scroller.clientHeight) {
+      scroller.scrollTop = rowBottom - scroller.clientHeight + OUTLINE_ROW_HEIGHT;
+    }
+  });
+
+  $effect(() => {
+    const el = scroller;
+    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="subh">
+  <span>Outline</span>
+  <span class="n">in source order</span>
+  <span class="n count">{total}</span>
+</div>
+
+<div class="outline" bind:this={listEl}>
+  {#if window_.before > 0}<div style:height={`${window_.before}px`}></div>{/if}
+  {#each rows.slice(window_.start, window_.end) as row, offset (row.entry.id)}
+    {@const index = window_.start + offset}
+    <button
+      type="button"
+      class="orow"
+      class:dimmed={row.dimmed}
+      class:sel={index === selected}
+      style:padding-left={`${4 + row.indent * 22}px`}
+      onclick={() => onopen(row.entry)}
+      onmouseenter={() => onhover?.(index)}
+      title={`${row.entry.qualifiedName} — line ${row.entry.line}`}
+    >
+      <KindGlyph kind={row.entry.kind} />
+      <span class="nm">{row.entry.name}</span>
+      <span class="sig">{row.entry.signature ?? ''}</span>
+      <span class="cnt">
+        {row.entry.line}{#if row.entry.fanIn}&nbsp;· ← {row.entry.fanIn}{/if}{#if row.entry.fanOut}&nbsp;·
+          → {row.entry.fanOut}{/if}
+      </span>
+    </button>
+  {/each}
+  {#if window_.after > 0}<div style:height={`${window_.after}px`}></div>{/if}
+</div>
+
+{#if rows.length === 0}
+  <div class="note">
+    Nothing was extracted from this file — it holds no symbols the graph
+    recognises, only top-level code, or a language without an extractor.
+  </div>
+{/if}
+
+{#if truncated}
+  <div class="note">
+    Showing {rows.length} of {total} symbols. The rest are in the index; this
+    screen caps what it draws.
+  </div>
+{/if}
+
+<style>
+  .subh {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    margin: 18px 0 4px;
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .subh .n {
+    color: var(--ink-3);
+    font-weight: 400;
+  }
+
+  .subh .count {
+    margin-left: auto;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .outline {
+    border-top: 1px solid var(--rule);
+  }
+
+  /* The height here is load-bearing: the windowing arithmetic above assumes
+     every row is exactly OUTLINE_ROW_HEIGHT tall. Any change must move both. */
+  .orow {
+    display: grid;
+    height: 28px;
+    box-sizing: border-box;
+    grid-template-columns: 16px minmax(160px, auto) 1fr auto;
+    width: 100%;
+    align-items: center;
+    gap: 10px;
+    padding: 0 4px;
+    border-bottom: 1px solid var(--rule-faint);
+    text-align: left;
+  }
+
+  .orow:hover,
+  .orow.sel {
+    background: var(--press);
+  }
+
+  .nm {
+    overflow: hidden;
+    font: 12.5px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .orow.dimmed .nm {
+    color: var(--ink-3);
+  }
+
+  .sig {
+    overflow: hidden;
+    color: var(--ink-3);
+    font: 11.5px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .cnt {
+    color: var(--ink-3);
+    font: 11px var(--mono);
+    font-variant-numeric: tabular-nums;
+    white-space: nowrap;
+  }
+
+  .note {
+    padding: 10px 0;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.5;
+  }
+</style>

+ 209 - 0
ui/src/components/file/FileRail.svelte

@@ -0,0 +1,209 @@
+<!--
+  One side of the File view: the files this one depends on, or the files that
+  depend on it (design spec §3.4).
+
+  Both rails are the same component because the row is the same thing in both
+  directions — a file, whether it is reachable or reaching. The count in the
+  header is the engine's own `getFileDependencies` / `getFileDependents`
+  answer, so a rail and a blast radius can never disagree about how far a
+  change here goes.
+
+  Imports that resolved to nothing indexed — packages, runtime builtins — sit
+  below the files, in `--ink-3` and not clickable. Leaving them out would make
+  a file importing `react`, `fs` and one local module show a single row and
+  read as broken.
+-->
+<script lang="ts">
+  import { fileHref } from '../../lib/router.svelte';
+  import { plural } from '../../lib/symbol-model';
+  import type { FileRailModel, FileRailRow } from '../../lib/file-model';
+
+  interface Props {
+    title: string;
+    model: FileRailModel;
+    /** "none in the graph" reads wrong for both directions; each says its own. */
+    emptyNote: string;
+    side: 'left' | 'right';
+    /** Index of the keyboard's position in this rail, or -1. */
+    selected?: number;
+    onhover?: (index: number) => void;
+  }
+
+  let { title, model, emptyNote, side, selected = -1, onhover }: Props = $props();
+
+  /**
+   * A path is drawn as a shrinkable directory plus a basename that never
+   * truncates: the last segment is what tells two `index.ts` apart, so it is
+   * the one part of a 300px column that must survive.
+   */
+  function dirOf(path: string): string {
+    const cut = path.lastIndexOf('/');
+    return cut < 0 ? '' : path.slice(0, cut + 1);
+  }
+
+  function baseOf(path: string): string {
+    return path.slice(path.lastIndexOf('/') + 1);
+  }
+
+  function rowTitle(row: FileRailRow): string {
+    if (row.symbols.length === 0) return row.path;
+    const names = row.symbols.map((s) => s.name).join(', ');
+    const more = row.symbolCount > row.symbols.length ? ', …' : '';
+    return `${row.path} — ${names}${more}`;
+  }
+</script>
+
+<div class="rail" class:right={side === 'right'} aria-label={title}>
+  <div class="rail-h">
+    <span>{title} <span class="n">{model.total}</span></span>
+  </div>
+
+  {#if model.rows.length === 0}
+    <div class="note">{emptyNote}</div>
+  {/if}
+
+  {#each model.rows as row, index (row.path)}
+    <a
+      class="filerow"
+      class:test={row.test}
+      class:sel={index === selected}
+      href={fileHref(row.path)}
+      title={rowTitle(row)}
+      onmouseenter={() => onhover?.(index)}
+    >
+      <span class="p"><span class="dir">{dirOf(row.path)}</span><span class="base"
+          >{baseOf(row.path)}</span
+        ></span>
+      {#if row.symbolCount > 0}
+        <span class="n2">{row.symbolCount}</span>
+      {/if}
+    </a>
+  {/each}
+
+  {#if model.testCount > 0 && model.testCount < model.rows.length}
+    <div class="note dim">
+      {plural(model.testCount, 'test file')} at the end of the list.
+    </div>
+  {/if}
+
+  {#if model.outside.length > 0}
+    <div class="sub">
+      Outside the index <span class="n">{model.outside.length}</span>
+    </div>
+    {#each model.outside as row (row.name)}
+      <div class="filerow outside" title={`imported at line ${row.lines.join(', ')}`}>
+        <span class="p"><span class="dir">{row.name}</span></span>
+        {#if row.lines.length > 1}<span class="n2">×{row.lines.length}</span>{/if}
+      </div>
+    {/each}
+    <div class="note dim">
+      Packages and runtime modules — nothing was indexed for them, so this
+      viewer cannot open them.
+    </div>
+  {/if}
+</div>
+
+<style>
+  .rail {
+    overflow: auto;
+    height: 100%;
+    border-right: 1px solid var(--rule-soft);
+    background: var(--paper);
+  }
+
+  .rail.right {
+    border-right: none;
+    border-left: 1px solid var(--rule-soft);
+  }
+
+  .rail-h {
+    position: sticky;
+    top: 0;
+    z-index: 2;
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    padding: 12px 14px 8px;
+    border-bottom: 1px solid var(--rule-soft);
+    background: var(--paper);
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .rail-h .n,
+  .sub .n {
+    color: var(--ink-3);
+    font-weight: 400;
+  }
+
+  .sub {
+    margin-top: 14px;
+    padding: 10px 14px 4px;
+    border-top: 1px solid var(--rule-soft);
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+
+  .filerow {
+    display: grid;
+    grid-template-columns: 1fr auto;
+    gap: 8px;
+    align-items: baseline;
+    padding: 5px 14px;
+    border-bottom: 1px solid var(--rule-faint);
+    color: var(--ink-2);
+    font: 12px var(--mono);
+    text-decoration: none;
+  }
+
+  a.filerow:hover,
+  a.filerow.sel {
+    background: var(--press);
+    color: var(--ink);
+  }
+
+  /* Not a link, and drawn so — nothing was indexed to open. */
+  .filerow.outside {
+    color: var(--ink-3);
+    cursor: default;
+  }
+
+  .p {
+    display: flex;
+    min-width: 0;
+  }
+
+  .dir {
+    overflow: hidden;
+    flex: 0 1 auto;
+    color: var(--ink-3);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .base {
+    flex: 0 0 auto;
+    white-space: nowrap;
+  }
+
+  .filerow.test .base {
+    color: var(--ink-3);
+  }
+
+  .n2 {
+    color: var(--ink-3);
+    font-size: 11px;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .note {
+    padding: 8px 14px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.4;
+  }
+
+  .note.dim {
+    color: var(--ink-4);
+  }
+</style>

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

@@ -164,6 +164,57 @@ export interface WireSource {
   highlight?: WireHighlight;
 }
 
+/* ------------------------------------------------------------- file view -- */
+
+/** A row in the file outline — a symbol, its nesting and its edge counts. */
+export interface WireOutlineEntry extends WireNodeRef {
+  /** Containing symbol within this file, or null for a top-level one. */
+  parentId: string | null;
+  /** Nesting depth from the top level of the file, starting at 0. */
+  depth: number;
+  fanIn: number;
+  fanOut: number;
+}
+
+/** One file at the far end of an import rail, with the symbols the edges name. */
+export interface WireImportRow {
+  file: string;
+  test: boolean;
+  symbols: Array<{ id: string; name: string; kind: string; line: number }>;
+  symbolCount: number;
+}
+
+export interface WireFilePayload {
+  file: {
+    path: string;
+    language: string;
+    size: number;
+    modifiedAt: number;
+    indexedAt: number;
+    contentHash: string;
+    nodeCount: number;
+    generated: boolean;
+    test: boolean;
+    errors: string[];
+    /** The file node's own id, so the viewer can open the file AS a symbol. */
+    id: string | null;
+  };
+  /** Calls made outside every definition — module-level code. */
+  topLevel: { calls: number };
+  /** The file changed on disk since it was indexed; the outline's lines shifted. */
+  drift: boolean;
+  outline: WireList<WireOutlineEntry>;
+  /** `imports` edges only — a subset of `dependencies`, with symbol names. */
+  imports: WireList<WireImportRow>;
+  importedBy: WireList<WireImportRow>;
+  /** Import statements that resolved to nothing indexed: packages, builtins. */
+  unresolvedImports: Array<{ name: string; line: number }>;
+  /** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
+  dependencies: string[];
+  /** Every file that reaches into this one — `getFileDependents`. */
+  dependents: string[];
+}
+
 export interface WireBlastScale {
   maxDirect: number;
   maxWithinHops: number;
@@ -354,6 +405,13 @@ export function fetchEntryPoints(
   return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
 }
 
+export function fetchFile(path: string, signal?: AbortSignal): Promise<WireFilePayload> {
+  // Paths carry '/'; encode per segment so `api/file/src/mcp/tools.ts` stays
+  // readable and a segment with a reserved character still round-trips.
+  const encoded = path.split('/').map(encodeURIComponent).join('/');
+  return getJson<WireFilePayload>(`api/file/${encoded}`, signal);
+}
+
 export function fetchSource(
   file: string,
   from: number,

+ 200 - 0
ui/src/lib/file-model.ts

@@ -0,0 +1,200 @@
+/**
+ * The File view's models (design spec §3.4, task CG-46).
+ *
+ * Pure functions over `/api/file`'s payload, kept out of the components so the
+ * decisions below can be tested without a browser.
+ *
+ * The one decision worth stating up front: **the import rails are drawn from
+ * `dependencies` / `dependents`, not from `imports` / `importedBy`.** They are
+ * different questions. An `imports` edge means an import STATEMENT resolved to
+ * a symbol in another file; `getFileDependencies` follows every cross-file edge
+ * — the calls, the type references, the instantiations. On this repo
+ * `src/graph/traversal.ts` imports two files and depends on four: it reaches
+ * `src/resolution/lru-cache.ts` through a call, with no import naming it. A
+ * rail labelled "Imports 2" would be quietly wrong about what breaking that
+ * file would touch, which is the only reason to look at this screen.
+ *
+ * The import rows are not discarded — they are merged in, because they carry
+ * the SYMBOL names, which is the detail a bare file path cannot give.
+ */
+
+import type { WireFilePayload, WireImportRow, WireOutlineEntry } from './api';
+import { basename } from './symbol-model';
+
+export { basename } from './symbol-model';
+
+/** Kinds that are dimmed in an outline: data, not behaviour. */
+const QUIET_KINDS = new Set(['property', 'field', 'enum_member', 'variable', 'constant']);
+
+/** Above this many rows the outline is windowed rather than fully rendered. */
+export const OUTLINE_VIRTUAL_THRESHOLD = 250;
+
+/** Fixed row height the windowed outline measures with — pinned in the CSS. */
+export const OUTLINE_ROW_HEIGHT = 28;
+
+/* ----------------------------------------------------------------- rails -- */
+
+export interface RailSymbol {
+  id: string;
+  name: string;
+  kind: string;
+  line: number;
+}
+
+export interface FileRailRow {
+  path: string;
+  /** Test or fixture code — sorted below production rows and marked. */
+  test: boolean;
+  /** An import statement names this file, so its symbols are known. */
+  imported: boolean;
+  /** Symbols the import edges name. Empty is normal — see `namedSymbols`. */
+  symbols: RailSymbol[];
+  symbolCount: number;
+}
+
+/** An import that resolved to nothing indexed: a package, a runtime builtin. */
+export interface OutsideRow {
+  name: string;
+  /** Every line importing it — the same package is often imported twice. */
+  lines: number[];
+}
+
+export interface FileRailModel {
+  rows: FileRailRow[];
+  /** Files in the index — equal to `rows.length`, and to the engine's count. */
+  total: number;
+  testCount: number;
+  outside: OutsideRow[];
+}
+
+/**
+ * Symbols worth naming on a rail row.
+ *
+ * An `importedBy` edge's far end is the *importing file's own file node*, so
+ * its "symbols" are a single entry repeating the path already in the row. Only
+ * real symbols say anything, so file nodes are dropped and a row with none
+ * shows no count rather than a misleading `1`.
+ */
+function namedSymbols(row: WireImportRow | undefined): RailSymbol[] {
+  if (!row) return [];
+  return row.symbols.filter((s) => s.kind !== 'file');
+}
+
+/**
+ * One rail: every file the engine says this one is related to, in reading
+ * order, with the import detail merged in.
+ *
+ * Production files first, then tests, each alphabetically. Tests are last
+ * because a file's test callers answer a different question than its callers —
+ * and on a hub like `src/types.ts` they would otherwise be most of the rail.
+ */
+export function buildFileRail(
+  paths: readonly string[],
+  importRows: readonly WireImportRow[],
+  unresolved: ReadonlyArray<{ name: string; line: number }> = []
+): FileRailModel {
+  const detail = new Map(importRows.map((row) => [row.file, row]));
+
+  const rows: FileRailRow[] = [...new Set(paths)].map((path) => {
+    const row = detail.get(path);
+    const symbols = namedSymbols(row);
+    return {
+      path,
+      test: row?.test ?? looksLikeTest(path),
+      imported: row !== undefined,
+      symbols,
+      symbolCount: symbols.length,
+    };
+  });
+
+  rows.sort(
+    (a, b) => Number(a.test) - Number(b.test) || a.path.localeCompare(b.path)
+  );
+
+  const byName = new Map<string, number[]>();
+  for (const item of unresolved) {
+    const lines = byName.get(item.name);
+    if (lines) lines.push(item.line);
+    else byName.set(item.name, [item.line]);
+  }
+
+  return {
+    rows,
+    total: rows.length,
+    testCount: rows.filter((row) => row.test).length,
+    outside: [...byName.entries()]
+      .map(([name, lines]) => ({ name, lines: [...lines].sort((a, b) => a - b) }))
+      .sort((a, b) => a.name.localeCompare(b.name)),
+  };
+}
+
+/**
+ * Whether a path looks like test code, for the rows no import edge described.
+ *
+ * The server's `isTestFile` reads more than a path — this is the fallback for
+ * a dependency that arrived as a bare string, and it errs towards *not* calling
+ * something a test: a production file sorted with the tests is a worse mistake
+ * than a test file sorted with production.
+ */
+export function looksLikeTest(path: string): boolean {
+  return (
+    /(^|\/)(__tests__|__mocks__|__fixtures__|tests?|spec|fixtures)(\/|$)/.test(path) ||
+    /\.(test|spec)\.[a-z0-9]+$/i.test(path)
+  );
+}
+
+/* --------------------------------------------------------------- outline -- */
+
+export interface OutlineEntryRow {
+  entry: WireOutlineEntry;
+  /** Indent step: 0 top level, 1 a member, 2 a member of a member. */
+  indent: number;
+  /** Data rather than behaviour — drawn quieter. */
+  dimmed: boolean;
+}
+
+/**
+ * The file's symbols in source order, nested under their container.
+ *
+ * The server has already sorted by line and resolved each entry's parent, so
+ * this only decides how a row is drawn. Depth is clamped: a deeply nested
+ * closure would otherwise indent itself off the right edge of the column.
+ */
+export function buildFileOutline(payload: WireFilePayload): OutlineEntryRow[] {
+  return payload.outline.items.map((entry) => ({
+    entry,
+    indent: Math.min(entry.depth, 3),
+    dimmed: QUIET_KINDS.has(entry.kind),
+  }));
+}
+
+/* ---------------------------------------------------------------- header -- */
+
+/** `24.2 KB` — sizes on this screen are for scale, never for accounting. */
+export function formatBytes(bytes: number): string {
+  if (!Number.isFinite(bytes) || bytes < 0) return '—';
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+/**
+ * The line under the file name: `typescript · 24.2 KB · 23 symbols · generated`.
+ *
+ * The symbol count is the outline's `total`, not the file record's node count —
+ * the record counts the file node and its import declarations, neither of which
+ * is a row, and a header that disagrees with the list under it is a bug the
+ * reader has no way to resolve.
+ */
+export function fileMetaLine(payload: WireFilePayload): string {
+  const parts = [payload.file.language, formatBytes(payload.file.size)];
+  parts.push(`${payload.outline.total} ${payload.outline.total === 1 ? 'symbol' : 'symbols'}`);
+  if (payload.file.generated) parts.push('generated');
+  if (payload.file.test) parts.push('test');
+  return parts.join(' · ');
+}
+
+/** The document title / heading for a file: its basename. */
+export function fileTitle(path: string): string {
+  return basename(path);
+}

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

@@ -1,28 +1,417 @@
 <!--
-  Placeholder for the file view: imported by | outline in source order |
-  imports (design spec §3.4, task CG-46).
+  The File view: imported by | outline in source order | imports
+  (design spec §3.4, task CG-46).
+
+  A file is the one unit of the graph that has no body worth printing and no
+  single caller — so the screen is three lists rather than the Symbol view's
+  code-and-rails. The middle column is the file's shape; the two rails are what
+  a change to it would reach, in both directions.
+
+  The counts on the rails come from `getFileDependencies` / `getFileDependents`
+  — 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.
 -->
 <script lang="ts">
+  import { tick, untrack } from 'svelte';
+  import FileOutline from '../components/file/FileOutline.svelte';
+  import FileRail from '../components/file/FileRail.svelte';
+  import KindGlyph from '../components/KindGlyph.svelte';
+  import { ApiFailure, fetchFile, type WireFilePayload, type WireNodeRef } from '../lib/api';
+  import {
+    basename,
+    buildFileOutline,
+    buildFileRail,
+    fileMetaLine,
+  } from '../lib/file-model';
+  import { fileHref, navigate } from '../lib/router.svelte';
+  import { plural } 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<WireFilePayload | null>(null);
+  let failure = $state<ApiFailure | null>(null);
+  let loading = $state(true);
+  let centerEl = $state<HTMLElement | null>(null);
+  /** The `?hl=` this screen has already landed on — see the effect at the end. */
+  let landed: string | 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;
+    pane = 'outline';
+    index = -1;
+    // Leaving and coming back to the same `?hl=` URL must land again; the
+    // guard below only exists to stop a re-render re-selecting.
+    landed = null;
+    try {
+      const next = await fetchFile(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;
+    }
+  }
+
+  /* ------------------------------------------------------------- models -- */
+
+  let outline = $derived(payload ? buildFileOutline(payload) : []);
+
+  let importedBy = $derived(
+    payload ? buildFileRail(payload.dependents, payload.importedBy.items) : null
+  );
+
+  let imports = $derived(
+    payload
+      ? buildFileRail(payload.dependencies, payload.imports.items, payload.unresolvedImports)
+      : null
+  );
+
+  /* ------------------------------------------------------------ movement -- */
+
+  /**
+   * Opening a symbol from a file is a `start` hop: nothing on this screen was
+   * stepped through to reach it, so claiming a direction would draw an arrow
+   * in the trail that describes no call.
+   */
+  function open(node: WireNodeRef | { id: string; name: string; kind: string }): void {
+    walkTo({ id: node.id, name: node.name, kind: node.kind }, 'start');
+  }
+
+  /** Open the file AS a symbol — the only way to read its top-level code. */
+  function openFileNode(): void {
+    const file = payload?.file;
+    if (!file?.id) return;
+    walkTo({ id: file.id, name: basename(file.path), kind: 'file' }, 'start');
+  }
+
+  /* ------------------------------------------------------------ keyboard -- */
+
+  type Pane = 'left' | 'outline' | 'right';
+  const PANES: Pane[] = ['left', 'outline', 'right'];
+
+  let pane = $state<Pane>('outline');
+  let index = $state(-1);
+
+  function paneLength(which: Pane): number {
+    if (which === 'left') return importedBy?.rows.length ?? 0;
+    if (which === 'right') return imports?.rows.length ?? 0;
+    return outline.length;
+  }
+
+  function follow(): void {
+    if (index < 0) return;
+    if (pane === 'outline') {
+      const row = outline[index];
+      if (row) open(row.entry);
+      return;
+    }
+    const rail = pane === 'left' ? importedBy : imports;
+    const row = rail?.rows[index];
+    if (row) navigate(fileHref(row.path));
+  }
+
+  function switchPane(delta: number): void {
+    const at = PANES.indexOf(pane);
+    const next = PANES[Math.max(0, Math.min(PANES.length - 1, at + delta))];
+    if (!next || next === pane) return;
+    // Skip an empty rail rather than parking the selection somewhere invisible.
+    if (paneLength(next) === 0) return;
+    pane = next;
+    index = 0;
+  }
+
+  function onkeydown(event: KeyboardEvent): void {
+    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
+    const target = event.target;
+    if (
+      target instanceof HTMLElement &&
+      (target.isContentEditable ||
+        target instanceof HTMLInputElement ||
+        target instanceof HTMLTextAreaElement ||
+        target instanceof HTMLSelectElement)
+    ) {
+      return;
+    }
+    if (!payload) return;
+
+    switch (event.key) {
+      case 'ArrowLeft':
+        switchPane(-1);
+        break;
+      case 'ArrowRight':
+        switchPane(1);
+        break;
+      case 'ArrowDown':
+      case 'j':
+        index = Math.max(0, Math.min(paneLength(pane) - 1, index + 1));
+        break;
+      case 'ArrowUp':
+      case 'k':
+        index = Math.max(0, index - 1);
+        break;
+      case 'Enter':
+        event.preventDefault();
+        follow();
+        return;
+      default:
+        return;
+    }
+    event.preventDefault();
+    if (pane !== 'outline') {
+      // The outline scrolls itself (it is windowed); the rails need a nudge.
+      void tick().then(() => {
+        const scope = document.querySelector(
+          pane === 'left' ? '[data-pane="left"]' : '[data-pane="right"]'
+        );
+        scope?.querySelectorAll('.filerow')[index]?.scrollIntoView({ block: 'nearest' });
+      });
+    }
+  }
+
+  /* --------------------------------------------------- arriving at a line -- */
+
+  // `#/file/<path>?hl=<line>` lands on the symbol that owns the line. The
+  // outline is the only thing on this screen with line numbers, so the honest
+  // reading of "this file, at line 900" is "this file, at the symbol there".
+  $effect(() => {
+    const key = line === null ? null : `${path}:${line}`;
+    if (!key || !payload || landed === key || outline.length === 0) return;
+    // The LAST containing row, not the first: rows are in source order, so a
+    // symbol's descendants follow it, and the deepest one that still holds the
+    // line is the specific answer. Landing on the enclosing class instead
+    // would point at every line in the file equally.
+    let at = -1;
+    outline.forEach((row, i) => {
+      if (row.entry.line <= line! && line! <= row.entry.endLine) at = i;
+    });
+    landed = key;
+    if (at < 0) return;
+    pane = 'outline';
+    index = at;
+  });
 </script>
 
-<div class="scroll">
-  <div class="emptystate">
-    <h2>File view</h2>
-    <p>
-      <span class="mono">{path}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
-    </p>
-    <p>The file's outline and its import rails are not wired up in this build yet.</p>
+<svelte:window {onkeydown} />
+
+{#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 || !imports || !importedBy}
+  <div class="scroll">
+    <div class="emptystate"><p class="dim">Loading…</p></div>
   </div>
-</div>
+{:else}
+  <div class="fileview">
+    <div class="pane" data-pane="left">
+      <FileRail
+        title="Imported by"
+        model={importedBy}
+        side="left"
+        selected={pane === 'left' ? index : -1}
+        onhover={(i) => {
+          pane = 'left';
+          index = i;
+        }}
+        emptyNote="Nothing in the index reaches into this file. It is either an entry point, or nothing depends on it yet."
+      />
+    </div>
+
+    <section class="center" bind:this={centerEl}>
+      <div class="card-h">
+        <KindGlyph kind="file" />
+        <h1>{basename(payload.file.path)}</h1>
+        <span class="kindword">{fileMetaLine(payload)}</span>
+        <span class="loc">{payload.file.path}</span>
+      </div>
+
+      <div class="badges">
+        {#if payload.topLevel.calls > 0}
+          <span class="badge">
+            Runs {plural(payload.topLevel.calls, 'call')} at the top level —
+            <button type="button" class="linkish" onclick={openFileNode}>
+              see what it calls
+            </button>
+          </span>
+        {/if}
+        {#if payload.file.generated}
+          <span class="badge">generated</span>
+        {/if}
+        {#if payload.file.errors.length > 0}
+          <span class="badge warn">
+            {plural(payload.file.errors.length, 'extraction error')} — the outline may be
+            incomplete
+          </span>
+        {/if}
+      </div>
+
+      {#if payload.drift}
+        <div class="drift">
+          This file changed on disk after the last index sync, so the line numbers below
+          are the ones it had when it was indexed. Run <code>codegraph sync</code> to bring
+          them up to date.
+        </div>
+      {/if}
+
+      <FileOutline
+        rows={outline}
+        total={payload.outline.total}
+        truncated={payload.outline.truncated}
+        scroller={centerEl}
+        selected={pane === 'outline' ? index : -1}
+        onopen={open}
+        onhover={(i) => {
+          pane = 'outline';
+          index = i;
+        }}
+      />
+    </section>
+
+    <div class="pane" data-pane="right">
+      <FileRail
+        title="Imports"
+        model={imports}
+        side="right"
+        selected={pane === 'right' ? index : -1}
+        onhover={(i) => {
+          pane = 'right';
+          index = i;
+        }}
+        emptyNote="This file reaches nothing else in the index — it depends on nothing the graph holds."
+      />
+    </div>
+  </div>
+{/if}
 
 <style>
   .scroll {
     height: 100%;
     overflow: auto;
   }
+
+  .fileview {
+    display: grid;
+    grid-template-columns: 300px minmax(480px, 1fr) 300px;
+    height: 100%;
+    min-height: 0;
+  }
+
+  .pane {
+    min-width: 0;
+    overflow: hidden;
+  }
+
+  .center {
+    min-width: 0;
+    overflow: auto;
+    padding: 18px 22px 40px;
+  }
+
+  .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;
+  }
+
+  .kindword {
+    color: var(--ink-3);
+    font-size: 12.5px;
+  }
+
+  .loc {
+    color: var(--ink-2);
+    font: 11.5px var(--mono);
+  }
+
+  .badges {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+  }
+
+  .badges:not(:empty) {
+    margin-top: 10px;
+  }
+
+  .badge {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    padding: 2px 7px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink-2);
+    font-size: 11.5px;
+  }
+
+  .badge.warn {
+    border-color: var(--amber);
+    background: var(--amber-soft);
+    color: var(--amber);
+  }
+
+  .linkish {
+    color: var(--accent);
+    font: inherit;
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+
+  .drift {
+    margin-top: 12px;
+    padding: 10px 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);
+  }
+
+  @media (max-width: 1100px) {
+    .fileview {
+      grid-template-columns: 220px minmax(360px, 1fr) 220px;
+    }
+  }
 </style>

+ 7 - 0
ui/src/views/HomeView.svelte

@@ -12,6 +12,7 @@
   import PaletteRows from '../components/PaletteRows.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
+  import { fileHref, navigate } from '../lib/router.svelte';
   import { walkTo } from '../lib/walk';
 
   interface Props {
@@ -28,6 +29,12 @@
   function pick(item: PaletteItem) {
     const id = item.type === 'route' ? item.nodeId : item.id;
     if (!id) return;
+    // A file opens the File view — its outline plus the import rails. The
+    // entry-point rows are files far more often than the palette's are.
+    if (item.type === 'symbol' && item.node.kind === 'file') {
+      navigate(fileHref(item.node.file));
+      return;
+    }
     walkTo(
       item.type === 'route'
         ? { id, name: item.handler, kind: null }