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