Files
codegraph/ui/src/App.svelte
T
Colby McHenryandClaude Opus 5 94f4e287e6 feat(ui): entry points — routes, executable files and tests as flow starting points (CG-54)
`#/entry` answers "where does anything start" at full length, and turns any row
that names a symbol into a flow.

Server. `/api/entrypoints` gains `frameworks` (from `getDetectedFrameworks`), a
`tests` list, a `routes` limit of its own, and a cache keyed on the index build
— nothing here is read from disk, so unlike `/api/source` a cached answer cannot
be stale about drift. `routes.items` is now a `WireList` like every other list on
the payload.

Routes carry where the URL is REGISTERED as well as where it is served:
`getRoutingManifest` selects the route node's id, file and line, and
`buildRoutes` splits the verb off the name against a fixed list (never "the
first word", which would take the head off a file-routed `/blog/[slug]`). All
four payroll-go routes register in one router file and three are served from
another — group by the handler file and one router becomes two groups plus an
orphan.

`isTestFile` is split into `isTestPath` (test filename and directory
conventions) + the non-production catch-all, byte-identical at every existing
call site. The Tests list uses the narrow half: an example, a benchmark or a
fixture is off-target for ranking but is not a test, and a heading that says
"Tests" must not quietly count them. Tests rank by REACH — distinct other files
touched — because Go, Rust and Java put test work inside functions where a
module-level-calls ranking sees nothing. Two read-only engine queries make that
affordable: `getFileReachCounts` (the mirror of `getFileDependentCounts`, driven
from `nodes` by path so the cost follows the files asked about rather than the
edge table) and `getFileNodes`.

Viewer. `ui/src/lib/entry-model.ts` folds the four lists into file groups —
pure, and `panel.rows` stays exactly the sections it draws. `EntryView` +
`EntrySection` render them with the caller rail's `.filegroup` / `.row` shapes
rather than a second visual language for the same idea. A row that names a
callable symbol carries a `Flow ›` chip; the other end is typed or picked with
`→ here` on another row. File and test rows carry none: `/api/flow` searches by
name, and a file has none the path finder can look up.

A project with fewer than three resolvable routes gets no Routes heading at all,
not an empty one. Typing into the search box now also returns matching entry
points under their own heading below the symbol matches, so a URL comes back
with its handler attached; rows already in the results are dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 05:20:15 -05:00

165 lines
5.3 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
import TopBar from './components/TopBar.svelte';
import TrailBar from './components/TrailBar.svelte';
import HomeView from './views/HomeView.svelte';
import SymbolView from './views/SymbolView.svelte';
import FileView from './views/FileView.svelte';
import FileCodeView from './views/FileCodeView.svelte';
import MapView from './views/MapView.svelte';
import FlowView from './views/FlowView.svelte';
import EntryView from './views/EntryView.svelte';
import NotFoundView from './views/NotFoundView.svelte';
import Toast from './components/Toast.svelte';
import { router, navigate, back, mapHref, flowHref, entryHref } from './lib/router.svelte';
import { palette } from './lib/palette.svelte';
import { trail, resolveTrailNames } from './lib/trail.svelte';
import { project } from './lib/project.svelte';
import { live } from './lib/live.svelte';
import { toast } from './lib/toast.svelte';
// One `/api/stats` for the whole app: the top bar's counts and the Symbol
// view's blast-radius denominator come out of the same payload.
$effect(() => {
void project.ensure();
});
// The live channel: one connection for the page, opened once. Every screen
// reads its counters; nothing polls.
$effect(() => {
live.start();
});
// The index moving is the one thing worth a note — the screen under it has
// already refetched by the time this shows. `/api/stats` is re-read for the
// same reason: the top bar's counts came from the graph that just changed.
let seenIndexTick = live.indexTick;
$effect(() => {
const tick = live.indexTick;
untrack(() => {
if (tick === seenIndexTick) return;
seenIndexTick = tick;
void project.reload();
// The entry points describe the index, and they are fetched once and
// kept — so without this the resting palette, the empty screen and the
// entry-points panel would all keep describing the graph as it was.
void palette.reloadEntries();
toast.show('Index updated · reloaded');
});
});
let topbar: TopBar | null = $state(null);
let route = $derived(router.route);
// Keep the in-memory trail and the `t` param in step. untrack() because the
// body writes the same store it would otherwise read itself into a loop.
$effect(() => {
const current = router.route;
const encoded = router.params.get('t');
untrack(() => {
trail.hydrate(encoded);
if (current.view === 'symbol' && trail.current?.id !== current.id) {
trail.push({ id: current.id });
}
});
});
// Hops restored from a URL carry ids and nothing else; one batched request
// turns the bar back into names. Runs after every trail change, and does
// nothing when every hop already has one.
$effect(() => {
void trail.hops.length;
void resolveTrailNames();
});
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
function onkeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
// Cmd/Ctrl+K reaches the search box even from inside another field.
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
topbar?.focusSearch();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
switch (event.key) {
case '/':
event.preventDefault();
topbar?.focusSearch();
break;
case 'm':
event.preventDefault();
navigate(mapHref());
break;
case 'f':
event.preventDefault();
navigate(flowHref());
break;
case 'e':
event.preventDefault();
navigate(entryHref());
break;
case 'Backspace':
case '[':
event.preventDefault();
back();
break;
}
}
</script>
<svelte:window {onkeydown} />
<TopBar bind:this={topbar} project={project.name} stats={project.summary} />
<TrailBar />
<main>
{#if route.view === 'symbol'}
<SymbolView id={route.id} line={route.line} />
{:else if route.view === 'file' && route.source}
<FileCodeView path={route.path} line={route.line} />
{:else if route.view === 'file'}
<FileView path={route.path} line={route.line} />
{:else if route.view === 'map'}
<MapView root={route.root} depth={route.depth} tests={route.tests} />
{:else if route.view === 'flow'}
<FlowView
from={route.from}
to={route.to}
symbols={route.symbols}
trailParam={route.trail}
/>
{:else if route.view === 'entry'}
<EntryView project={project.name} />
{:else if route.view === 'unknown'}
<NotFoundView path={route.path} />
{:else}
<HomeView project={project.name} />
{/if}
</main>
<Toast />
<style>
/* The shell grid lives on #app (index.html's mount host) in app.css —
Svelte's scoped styles cannot reach an element this component does not
render. Only <main>, which it does render, is styled here. */
main {
/* min-height:0 lets the row shrink so the view, not the page, scrolls. */
min-height: 0;
overflow: hidden;
}
</style>