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>
This commit is contained in:
Colby McHenry
2026-08-27 03:47:42 -05:00
co-authored by Claude Opus 5
parent 62e0a89b0e
commit bd99c5e99a
24 changed files with 3227 additions and 40 deletions
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;