A Dead code screen and a mark on the Map, both drawn from one derivation in src/graph/dead-code.ts so a second surface can never disagree with the first. The SQL half is four lines — no incoming edge but `contains`. It returns ~2 500 candidates on this repository and the shipped list is 20; everything in between is the feature. A candidate is dropped the moment there is any reason to believe something outside the graph reaches it: exported symbols and header declarations, test and generated files, abstract and interface members, anything carrying a `decorates` edge, overrides of an ancestor's member, names the language calls by itself, vendored directories, files nothing in the index reaches (those are islands, and the Map says so instead), names the resolver failed to resolve somewhere, and names shared with a symbol that IS referenced — the mis-resolution that leaves a used method with a self-edge and its twin with nothing. The last rule is the only one that is not a graph query: before a claim is made, the declaring file and every file that reaches it are read and the identifier counted, which is what catches the references the extractor never recorded (`this.handleMessage.bind(this)`, a call inside an object literal, a shorthand property). Every subtraction is counted and printed under the list with the scale it came from, and the caveat line above it never collapses: the claim is "no static reference in the index", not "unused". On the Map a module nothing depends on keeps its stroke and says so in its count line, and tool-generated files and modules recede to ink-4 there, in the map's file list, in search results and on the file screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
16 KiB
Markdown
319 lines
16 KiB
Markdown
# ui/ — the `codegraph ui` viewer, and `@colbymchenry/codegraph-ui`
|
||
|
||
One source tree, two builds.
|
||
|
||
- **The app** — the browser reader for an indexed project: Svelte 5 + Vite,
|
||
built as static files into `../dist/viewer` and served by the CLI over
|
||
loopback.
|
||
- **The library** — the same components, packaged with `svelte-package` into
|
||
`dist/` as `@colbymchenry/codegraph-ui`, so a host (CodeGraph Pro) renders
|
||
the Symbol view, the Flow strip and the Map over its **own** graph reads.
|
||
|
||
They are one tree on purpose. A forked component is a second answer to the same
|
||
question about the same graph, and sooner or later the two get quoted against
|
||
each other in a review.
|
||
|
||
An npm workspace of the engine, so `npm ci` at the repo root installs the
|
||
toolchain for both.
|
||
|
||
Design spec (every token, size and measurement):
|
||
`../docs/design/codegraph-ui-design-spec.md`.
|
||
|
||
## Build
|
||
|
||
```bash
|
||
npm run build # from the repo root: tsc -> copy-assets -> this app
|
||
npm run build:ui # just the app, plus the dist assertion
|
||
npm run build:lib # the LIBRARY: svelte-package -> ui/dist, plus its checks
|
||
npm run dev -w ui # Vite dev server on 127.0.0.1:5174
|
||
npm run check -w ui # svelte-check
|
||
```
|
||
|
||
`build:lib` is deliberately not part of `npm run build`: the CLI does not need
|
||
it, and a release that fails because a component library would not compile is a
|
||
release that failed for the wrong reason.
|
||
|
||
`npm run build` emits **`dist/viewer/`** (`index.html` + hashed assets).
|
||
`scripts/check-ui-build.mjs` then asserts the tree is complete, so a broken UI
|
||
build fails the release instead of shipping a CLI that serves a 404. The same
|
||
check runs again in `scripts/build-bundle.sh` (after the bundle stage copies
|
||
`dist`) and in `scripts/pack-npm.sh` (after each archive is unpacked).
|
||
|
||
### Why `dist/viewer` and not `dist/ui`
|
||
|
||
`src/ui/` is the engine's **terminal** UI (shimmer progress and its worker) and
|
||
tsc compiles it to `dist/ui/`. Pointing Vite there deletes those modules — the
|
||
CLI then dies at startup with `Cannot find module '../ui/shimmer-progress'` —
|
||
and would also leave the static server handing out compiled engine internals.
|
||
`check-ui-build.mjs` re-asserts the compiled engine is intact after every UI
|
||
build so that mistake cannot land twice.
|
||
|
||
## `@colbymchenry/codegraph-ui`
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { CodegraphUi, SymbolView, FlowStrip, ArchitectureMap }
|
||
from '@colbymchenry/codegraph-ui';
|
||
import '@colbymchenry/codegraph-ui/theme.css';
|
||
</script>
|
||
|
||
<CodegraphUi adapter={myAdapter} nav={myNavigation}>
|
||
<SymbolView id={symbolId} line={null} />
|
||
</CodegraphUi>
|
||
```
|
||
|
||
Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
|
||
`FileSourceView`, `EntryPointsView`, `DeadCodeView`, `TypeHierarchy`, `TrailBar`,
|
||
`SearchPalette`, `PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`,
|
||
`ExportButtons`, `CodegraphUi` — plus every pure model function the screens are
|
||
built from (`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`,
|
||
`buildHierarchyModel`, `tokensByLine`, …) and the `Wire*` types an adapter
|
||
answers in.
|
||
|
||
`TypeHierarchy` is the one screen that takes its data as a prop rather than
|
||
asking the adapter: it is part of `SymbolView`'s payload (`/api/node`'s
|
||
`hierarchy`), so a host that already holds a `WireSymbolPayload` can render the
|
||
tree on its own without a second read.
|
||
|
||
### The adapter is the only way data arrives
|
||
|
||
```ts
|
||
interface GraphAdapter {
|
||
stats(signal?): Promise<WireStats>;
|
||
search(query, opts?, signal?): Promise<WireSearch>;
|
||
node(id, signal?): Promise<WireSymbolPayload>;
|
||
nodes(ids, signal?): Promise<WireNodeRefs>;
|
||
source(request, signal?): Promise<WireSource>;
|
||
file(path, signal?): Promise<WireFilePayload>;
|
||
fileCode(path, signal?): Promise<WireFileCodePayload>;
|
||
flow(request, signal?): Promise<WireFlowPayload>;
|
||
map(request?, signal?): Promise<WireMapPayload>;
|
||
routes(request?, signal?): Promise<WireRoutes>;
|
||
entryPoints(request?, signal?): Promise<WireEntryPoints>;
|
||
deadCode(request?, signal?): Promise<WireDeadCode>;
|
||
events?(handlers): () => void; // optional: the live channel
|
||
}
|
||
```
|
||
|
||
The shapes are exactly what `src/ui-server/api/` serialises, and they live in
|
||
`src/lib/wire.ts` — no imports, no runtime — so a host can depend on the
|
||
vocabulary without depending on the viewer. The default implementation,
|
||
`createHttpAdapter()`, is the loopback JSON API; a host that already holds the
|
||
index implements the same twelve methods against its own reads and never makes
|
||
an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
|
||
built package but `lib/adapter.js` touches the network, because a screen that
|
||
reached past the adapter would be a screen that ignored the host.
|
||
|
||
`events` is optional. Omit it and nothing connects and nothing polls; a host
|
||
that learns about a sync some other way calls `live.signal('index')` instead,
|
||
which is the same code path the stream uses.
|
||
|
||
### Three things that will bite
|
||
|
||
1. **Import `theme.css` once.** Every component paints from the design tokens.
|
||
Override any variable on a narrower selector — including on a container,
|
||
since custom properties inherit; `<CodegraphUi theme="light">` uses exactly
|
||
that to put a light reader inside a dark application.
|
||
2. **The adapter and the navigation driver are module-level, not context.** The
|
||
pure model modules are plain TypeScript and cannot read a component's
|
||
context, so one page reads one project. `<CodegraphUi>` installs them during
|
||
initialisation, once — swapping projects means re-mounting the subtree
|
||
(`{#key project}`), not swapping the prop.
|
||
3. **Geometry is not themable.** 34px rail rows, the 300/320px rails, the 20px
|
||
code line: the Symbol view measures these against each other to put a callee
|
||
row beside the line that calls it. Colour and type are yours.
|
||
|
||
### Navigation
|
||
|
||
Every link the components build goes through a `NavigationDriver`
|
||
(`src/lib/navigation.ts`). The default is the viewer's own hash space
|
||
(`#/s/<id>`); a host installs one that addresses its app instead, and the rails,
|
||
breadcrumbs, chips and cards follow. They are hrefs rather than click handlers
|
||
because middle-click, cmd-click and "copy link address" are how people read
|
||
code.
|
||
|
||
The app's half — parsing the hash, holding the live route — is
|
||
`src/lib/router.svelte.ts`, which attaches `hashchange`/`popstate` listeners at
|
||
module scope and is therefore **pruned out of the published package**. Nothing a
|
||
host imports may drag a hash router into its application.
|
||
|
||
### Versioning and publishing
|
||
|
||
The package is versioned with the engine (`scripts/sync-ui-version.mjs` runs on
|
||
every `build:lib`): `@colbymchenry/codegraph-ui@X.Y.Z` is the reader for
|
||
`codegraph@X.Y.Z`, because the payload shapes are versioned with the binary that
|
||
serves them.
|
||
|
||
It is **prepared, not published.** `"private": true` in `package.json` is the
|
||
guard — npm refuses to publish it — and `scripts/pack-npm.sh` only builds the
|
||
tarball when `CODEGRAPH_PACK_UI=1`, into `release/npm-ui/` (never
|
||
`release/npm/`, whose `codegraph-*` glob the release workflow publishes).
|
||
Publishing is the maintainer's call and takes two deliberate edits.
|
||
|
||
## Layout
|
||
|
||
```
|
||
src/
|
||
index.ts the LIBRARY's entry — everything the package exports
|
||
main.ts fonts + tokens, mounts App into index.html's #app
|
||
app.css the app's reset, shell grid and primitives
|
||
lib/theme.css the design tokens (light/dark) + the Svelte Flow map
|
||
lib/adapter.ts GraphAdapter, createHttpAdapter, the registry
|
||
lib/wire.ts every Wire* payload shape — types only, no runtime
|
||
lib/api.ts the screens' calls, one line each, over the adapter
|
||
lib/navigation.ts href builders + navigate, behind a driver
|
||
App.svelte top bar / trail bar / main, global keys
|
||
lib/router.svelte.ts hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
|
||
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
|
||
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 + the end cap — a DAG (pure)
|
||
lib/filecode-model.ts the whole-file view: fixed line height, arcs, paging (pure)
|
||
lib/entry-model.ts the entry-points panel: rows, file groups, flow arming (pure)
|
||
lib/export-svg.ts the Flow strip and the Map as a standalone SVG (pure)
|
||
lib/export-image.ts rasterising that SVG to PNG, clipboard and download
|
||
lib/live.svelte.ts /api/events: two counters every screen refreshes from
|
||
lib/toast.svelte.ts the one transient note ("Index updated · reloaded")
|
||
components/ TopBar, TrailBar, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
|
||
views/ one component per route
|
||
```
|
||
|
||
Fonts (Archivo Variable, IBM Plex Mono) are vendored through `@fontsource*` and
|
||
emitted into `dist/viewer/assets`: a local reader must work offline and must not
|
||
announce the project to a font CDN.
|
||
|
||
## Export
|
||
|
||
The Flow strip's header and the Map's side panel carry **Copy image** (a PNG on
|
||
the clipboard) and **Download SVG** (a file for a README). Both render the
|
||
**light** theme whatever the viewer is set to — an image is read on somebody
|
||
else's screen — with 24px of paper around the drawing, a caption naming the path
|
||
or the root, and a "CodeGraph" mark in the corner.
|
||
|
||
`export-svg.ts` **serialises the layout object**; it does not scrape the DOM.
|
||
`buildFlowLayout` and `buildMapLayout` already compute every rectangle, port and
|
||
curve before a component renders, so the image and the screen come from one
|
||
piece of arithmetic and cannot disagree — and the exporter is a pure function
|
||
that a test can run with no browser at all. The output is presentation-only SVG
|
||
(no script, no `foreignObject`, no external reference), which is what GitHub
|
||
will render in a README.
|
||
|
||
Fonts travel as `font-family` stacks rather than embedded bytes. An SVG loaded
|
||
as an image may not fetch a webfont, so a raster falls back to the platform's
|
||
own monospace; every fallback in the stack advances at ~0.6em like IBM Plex
|
||
Mono, so the code grid survives and only the letterforms change.
|
||
|
||
## Routes
|
||
|
||
| hash | view |
|
||
|---|---|
|
||
| `#/` | nothing selected |
|
||
| `#/s/<id>?hl=<line>&t=<trail>` | symbol 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 |
|
||
| `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
|
||
| `#/entry` | entry points — routes, files that run something, tests, hubs |
|
||
|
||
## Entry points
|
||
|
||
`#/entry` draws `/api/entrypoints` as file groups, reusing the Symbol view's
|
||
`.filegroup` / `.row` shapes rather than inventing a second visual language for
|
||
"a list of code, grouped by where it lives". Three things about it are decisions,
|
||
not accidents:
|
||
|
||
- **Routes group by where the URL is REGISTERED, not where it is served.** A
|
||
router file is the shape a reader already has in mind; handlers scatter across
|
||
a package. The payload carries both, and the row's meta line names the handler
|
||
and its `file:line`.
|
||
- **A row offers a flow only if it names a callable symbol.** `/api/flow`
|
||
searches the graph by NAME, and a file has none the path finder can look up —
|
||
so route and hub rows carry a `Flow ›` chip and file and test rows do not. A
|
||
chip that always failed would be worse than no chip.
|
||
- **No empty Routes box.** A project with fewer than three resolvable routes is
|
||
not a routed app, and the section is absent rather than empty; the panel falls
|
||
back to the files that run something and the tests that exercise them.
|
||
|
||
`buildEntryPanel` is pure and keeps `panel.rows` exactly equal to the sections it
|
||
draws, the same identity the search palette rests its keyboard on.
|
||
|
||
## Where the graph stops
|
||
|
||
A flow that does not reach everything it was asked about carries a
|
||
`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node
|
||
one column past the symbol the path stopped at, joined by a dotted `2 4` link
|
||
labelled "end of static path" that deliberately has **no arrowhead** — an arrow
|
||
would point at a continuation, and the absence of one is the finding.
|
||
|
||
Two rules hold it together:
|
||
|
||
- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every
|
||
sentence the cap shows and `endCapHeight()` measures them; the component then
|
||
renders exactly what was measured. Change the wording in one and the other
|
||
moves with it — they are the same function read twice.
|
||
- **One cap per stopping symbol, not per flow.** Two paths that run out at the
|
||
same place ran out for the same reason, and two caps side by side would read
|
||
as two different findings.
|
||
|
||
The verdict itself is not computed here or in the server: it is
|
||
`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
|
||
detector `codegraph_explore` announces boundaries with.
|
||
|
||
## The type hierarchy
|
||
|
||
A class, interface, struct, trait or enum carries a `hierarchy` on its
|
||
`/api/node` payload: ancestors up, subtypes down, and the fan an interface call
|
||
dispatches into. `buildHierarchyModel` turns it into a tree whose geometry is
|
||
arithmetic — 24px rows, 22px of indent per descendant level, orthogonal 1px
|
||
connectors computed from those two numbers. Nothing is measured; the same
|
||
payload always draws the same picture.
|
||
|
||
The details worth knowing before changing it:
|
||
|
||
- **`extends` is solid, `implements` dashed `4 3`, a synthesized edge dashed
|
||
`6 3`** with a `via <mechanism>` pill. In Go, `System` satisfies `Clock`
|
||
without either file naming the other and the edge exists only because the
|
||
resolver made it — the block says so rather than drawing it like a parse.
|
||
- **Overrides on the members outline are a NAME match**, not an `overrides`
|
||
edge (nothing in the engine emits one). They are matched against the nearest
|
||
ancestor that declares the name and are blind to signatures, and the tooltip
|
||
says which claim was actually checked.
|
||
- **The fold trims the deepest end**, because the walk is breadth-first: a
|
||
reader looking at an interface gets every direct implementation before any
|
||
subclass of one appears at all.
|
||
|
||
The walk is not computed here or in the server: it is `buildTypeHierarchy` in
|
||
`src/graph/type-hierarchy.ts`, whose `countImplementers` is also the number
|
||
`codegraph_explore` prints when it announces an interface dispatch — so "N types
|
||
implement X" is the same N wherever you read it.
|
||
|
||
## Live updates
|
||
|
||
The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on
|
||
`/api/events` for the life of the page and exposes two counters:
|
||
|
||
- **`indexTick`** — the graph moved (somebody synced). Every screen refetches:
|
||
a rail is an answer about the whole graph, and a symbol gains a caller when
|
||
some *other* file is edited, so filtering by the focused file would leave the
|
||
rails quietly wrong. One request per sync.
|
||
- **`diskTick`** — source files changed on disk and the index has not caught up.
|
||
Only the screen showing one of those files reacts, and what it does is draw a
|
||
drift banner.
|
||
|
||
`liveRefresh(file, refresh)` is the three lines of bookkeeping that turns a
|
||
counter into a single call; the Map and the Flow strip instead read
|
||
`live.indexTick` straight inside the effect that already fetches them.
|
||
|
||
Reconnection is ours, not `EventSource`'s: each failure closes the stream and
|
||
schedules ONE retry on a backoff that ends after eight attempts (~90 s), at
|
||
which point the top bar says "Not live" and nothing more is requested until the
|
||
tab is focused again. A `degraded` event — the server's watcher gave up — is
|
||
shown the same way and never answered with a poll.
|
||
|
||
Node ids and file paths are encoded per slash-separated segment, so
|
||
`#/file/src/mcp/tools.ts` stays readable and still round-trips a segment
|
||
containing a reserved character. Build hashes with `symbolHref()` /
|
||
`fileHref()` / `mapHref()` / `flowHref()` rather than by hand.
|