feat(ui): serve the upstream browser viewer - #298
Conversation
The numeric spec the owner selected on 2026-10-02 (Penpot file CodeGraph Rust Design, page D · Modern Tech), verbatim (sha256 16c066581552a0f2ad6dbf7307506645a36e085cc374ced1fd4cc62f2b264d11). It is the visual contract for the browser viewer port; the board PNGs stay out of the repository.
Verbatim copy of colbymchenry/codegraph v1.6.1 (f4ddf508516332419ea3c95702810765936cf679) ui/, MIT licensed. The tree equals upstream v1.6.1:ui byte for byte (7e3b440); every change to it follows in later commits.
Pin the dependencies upstream's lock resolved, commit a standalone package-lock.json, point the Vite build at crates/codegraph-ui/viewer, and run upstream's frontend suites under vitest (the model suites in node, the package suite in jsdom), plus the model cases of the three suites that mix engine and frontend: the hierarchy layout, link placement over the tokens the Rust highlighter sends, and the Entry points panel. The vendored ui/ tree and its build output keep upstream formatting.
Port upstream v1.6.1's dead-code report, named-symbol flow (named and directed modes), flow continuations, symbol lookup, type hierarchy and syntax-token classifier, with the read-only aggregate queries they need in codegraph-store. Add WatchOptions::observe_only, which replaces a watcher's two sync closures with reporters so it observes changes and never writes the index. No extraction, resolution or schema change; golden output is unchanged.
Add the codegraph-ui crate: a loopback HTTP server that answers upstream v1.6.1's viewer API from a project's existing index and serves the embedded ui/ build. It keeps upstream's boundary (Host and Origin checks, write marker, raw-path refusals, the containment chokepoint, CSP), wire shapes, limits and error codes, saves trails under the index root, and streams source and index changes over server-sent events from an observe-only watcher. Screens answers upstream's no-navigation result and Steps is not served until their builders are ported. Add codegraph ui (alias web), gated by CODEGRAPH_UI=1 before argument parsing exactly as upstream gates it, a UI CI job that rebuilds the bundle and byte-checks the committed copy, and the viewer reference.
Specify the viewer's light theme as section 3.6 of the direction D spec: the 39 Nebula token names with light values, their WCAG contrast against the six surfaces, the effect and canvas values, and how the theme is chosen. There are no Daylight boards; the rest of the file is unchanged.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a21e8521af
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let mut current = std::fs::canonicalize(start).ok()?; | ||
| loop { | ||
| if let Ok(paths) = session::resolve_paths(¤t) | ||
| && paths.current_db().is_file() | ||
| { | ||
| return Some(current); | ||
| } | ||
| if !current.pop() { |
There was a problem hiding this comment.
Validate the requested root before walking ancestors
When the requested path is a sensitive directory beneath an indexed ancestor, this walk promotes the request to that ancestor before cmd_ui performs validate_project_path; for example, requesting ~/.ssh can pass validation as $HOME when $HOME has an index. Validate start before ancestor lookup, or stop walking upward, so the credential-directory refusal and selected-project authority cannot be bypassed.
AGENTS.md reference: AGENTS.md:L55-L58
Useful? React with 👍 / 👎.
| FROM nodes r | ||
| JOIN edges e ON e.source = r.id | ||
| JOIN nodes h ON e.target = h.id | ||
| WHERE r.kind = 'route' | ||
| AND e.kind IN ('references', 'calls') | ||
| AND h.kind IN ('function', 'method', 'class', 'constant', 'variable') |
There was a problem hiding this comment.
Include handlerless file-based routes in the manifest
The inner join requires every route node to have a references or calls edge, but existing file-based extractors create route nodes without such edges—for example Next.js pages in crates/codegraph-resolve/src/frameworks/react.rs:236-248 and Nuxt pages in crates/codegraph-resolve/src/frameworks/vue.rs:145-163. Consequently, even a Next/Nuxt project with many indexed routes returns routed: false; preserve route nodes with no separate handler, such as by using a left join and treating the route file/node as the navigable target.
Useful? React with 👍 / 👎.
| fn routing_manifest(ctx: &Ctx<'_>, limit: i64) -> ApiResult<Option<Manifest>> { | ||
| let rows = ctx.store.routing_manifest_rows(limit)?; | ||
| let handler_files: Vec<String> = rows.iter().map(|r| r.handler_file.clone()).collect(); | ||
| let generated = ctx.store.generated_paths_among(&handler_files)?; | ||
| let filtered: Vec<RoutingRow> = rows | ||
| .into_iter() | ||
| .filter(|r| !is_low_value_file(&r.handler_file, &generated)) |
There was a problem hiding this comment.
Apply the route limit after low-value filtering
The SQL query is capped before test/generated handlers are removed, so when the first limit + 1 rows are low-value—such as a repository with more than 200 test routes sorted before its production routes—the later production rows are never inspected and the API can report routed: false or an incomplete, non-truncated manifest. Filter in SQL or fetch/filter until the requested number of production rows plus one has been found.
Useful? React with 👍 / 👎.
| let handler_files: Vec<String> = rows.iter().map(|r| r.handler_file.clone()).collect(); | ||
| let generated = ctx.store.generated_paths_among(&handler_files)?; | ||
| let filtered: Vec<RoutingRow> = rows | ||
| .into_iter() | ||
| .filter(|r| !is_low_value_file(&r.handler_file, &generated)) |
There was a problem hiding this comment.
Filter test routes by their declaration file
This checks only the handler file, so a route declared in a test or generated router file remains visible whenever it points at a production handler. That makes test-only URLs count toward the three-route threshold and appear as product entry points; classify both route_file and handler_file (and include both in the generated-path lookup) before retaining a row.
Useful? React with 👍 / 👎.
| for (id, dir) in hops { | ||
| let Some(node) = by_id.get(id) else { | ||
| missing.push(id.clone()); | ||
| continue; |
There was a problem hiding this comment.
Preserve gaps when stale trail hops are missing
When a bookmarked trail contains a stale middle node ID after re-indexing, this branch silently skips it and then evaluates the next surviving node against raw.last(). The response can therefore stitch the nodes on either side of the missing hop into a new edge and return a non-partial flow labeled as the original trail; retain the gap or select only a consecutive resolved run instead of joining across missing IDs.
Useful? React with 👍 / 👎.
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (91.60%) is below the target coverage (95.00%). You can increase the patch coverage or adjust the target coverage. @@ Coverage Diff @@
## main #298 +/- ##
==========================================
- Coverage 95.32% 95.05% -0.28%
==========================================
Files 161 197 +36
Lines 99751 108387 +8636
==========================================
+ Hits 95088 103025 +7937
- Misses 4663 5362 +699
... and 6 files with indirect coverage changes 🚀 New features to boost your workflow:
|
On Windows `/` resolves to the root of the current drive, which is not on the sensitive list (upstream's own test is POSIX-only for that reason), so the check failed on the Windows runner. Port upstream's three cases: the POSIX roots, a normal directory, and the Windows entries in any case.
Summary
Phase 1, part A of the browser viewer (UI family, upstream
v1.6.1): a Rust port of upstream's loopback viewer server and thecodegraph uicommand, serving upstream's ownui/frontend unchanged apart from one line. The look stays upstream's here. The direction D restyle and the light theme follow in the stacked PR-B.codegraph-uicrate (new). An axum server on127.0.0.1that answers upstream's viewer API from an existing index:/api,stats,search,node,nodes,source,file,filecode,routes,entrypoints,map,deadcode,flow,screens,events(SSE) andtrails. Wire shapes, limits, error codes ({error, code, hint?}, 400/403/404/503/500) and refusals all follow upstream. Every request opens the published index read-only throughStore::open_for_readand drops it when it answers. The bundle undercrates/codegraph-ui/viewer/is embedded bybuild.rs, socargo install --gitstill needs no Node.Boundary, ported with its tests:
HostandOriginallowlists (the DNS-rebinding guard);x-codegraph-ui: 1and a JSON body type, and are capped at 64 KB, measured on the bytes that actually arrive;.., control-byte, backslash and bad-escape refusals;nosniffandX-Frame-Options: DENY; no CORS headers.Engine ports the routes need, all SELECT-only:
codegraph-graph);codegraph-store);WatchOptions::observe_only(codegraph-watch). It swaps the watcher's two sync closures for reporters, so/api/eventsreuses the real watcher (per-platform registration, symlink mapping, topology escalation) and never writes the index.CLI.
codegraph ui [path] [--port N] [--no-open] [--read-only], aliasweb. It is gated byCODEGRAPH_UI=1before argument parsing, exactly as upstream gates it, and stays hidden from--helpunless enabled. The banner, port fallback (4747 plus 20) andCODEGRAPH_BROWSERmatch upstream, and SIGINT/SIGTERM shut it down.Trails, the viewer's one write, go to
<index root>/ui/trails/<slug>.json. The temp file is renamed into place atomically, so aCODEGRAPH_DIRoverride moves trails with the index. Each hop is re-resolved on read (ok / moved / ambiguous / missing).--read-onlyrefuses saves with 403.Frontend.
ui/is imported verbatim atv1.6.1in its own commit; that commit's tree equalsv1.6.1:ui(7e3b4400…). A separate commit adds the build and test harness:package-lock.json;stepsfrom the HTTP adapter (see below).CI. A new
UIjob runsmake ui-check, which:npm ci,svelte-check, vitest andvite build;CI Successnow needs it.Docs.
docs/ui.md(new) anddocs/design/viewer-d.md(the selected Penpot spec, plus §3.6 Daylight).docs/cli.md,docs/architecture.md,docs/README.md,AGENTS.md, and the README entry in both languages.UPSTREAM.mdentry with the re-audit (the latest stable tag is stillv1.6.1) and the phase 2–4 table.Differences from upstream (KEEP-RUST; pinned in tests and listed in
docs/ui.md)/api/stepsis not served yet, because its builders and branch guards are phases 2–3. It answers a JSON 404 with a hint. The frontend's HTTP adapter is built withoutsteps, so the Steps view shows upstream's own "cannot draw steps" state./api/screensanswers upstream's own early return for a graph with nonavigatesedges. No Rust index has one until F1.when) are absent (phase 2).via …hop and Go's implicit interfaces are absent from the hierarchy.countImplementersand the hierarchy'soverrides: falseswitch are MCP-only upstream and are not ported.CODEGRAPH_VIEWER_PATHhas no counterpart, because the bundle is embedded.Differences from the approved plan
assets/*withCache-Control: public, max-age=31536000, immutable. With fixed names a browser would keep running the old bundle after an upgrade. Hashed names keep upstream's caching correct, and the build is still byte-reproducible: the local spike rebuilt it identically under Node 22 and Node 26, and theUIjob re-checks it under Node 24.make ui-checkstep, not five steps withworking-directory: ui. It is the same sequence, and it is the same targetmake pre-ciruns locally, so local and CI cannot drift.Verification (evidence under
/tmp/evidence-ui/)make pre-ciexited 0 on the pushed heada21e852(run by the pre-push hook,pre-ci-a4-prepush.log) and on6f6b290, the code head before the §3.6 doc commit (pre-ci-a3.log):-D warningsall pass;make ui-checkpasses: svelte-check reports 0 errors, 17 files and 365 tests pass, and the bundle rebuilds byte-identically;regen-goldens.sh, release build of6f6b290): all 19 re-indexable corpora are identical (golden-drift-a.log). This PR makes no extraction, resolution or schema change.api58,server26,routes5,trails15,events15,map14,flow24,filecode15,entrypoints9,hierarchy12,deadcode19,highlight28,codegraph_dir1;cli_ui15, thehierarchy_boundsengine tests, and unit tests ported from upstream's own cases;observe_onlywatcher tests: they check the logical path, symlinked directories and ancestor removal, and that the.codegraphbytes are unchanged.git archiveof this branch (674 files, 21,906 nodes) and a NestJS routed fixture. Every route was loaded at 375 / 768 / 1280 / 1440: 40 + 16 page loads, with 0 console errors, 0 exceptions and 0 failed requests (accept-a-repo.json,accept-a-routed.json, screenshots inshots-a/).Not in this PR
UPSTREAM.md.🤖 Generated with Claude Code