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>
This commit is contained in:
Colby McHenry
2026-08-27 05:20:15 -05:00
co-authored by Claude Opus 5
parent dc7f1e590e
commit 94f4e287e6
28 changed files with 2400 additions and 81 deletions
+26 -2
View File
@@ -39,15 +39,16 @@ src/
main.ts fonts + tokens, mounts App into index.html's #app
app.css design tokens (light/dark), reset, shell grid
App.svelte top bar / trail bar / main, global keys
lib/router.svelte.ts hash router: #/s/<id>, #/file/<path>, #/map, #/flow
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/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, map/, flow/, symbol/, file/
components/ TopBar, TrailBar, KindGlyph, DriftBanner, Toast, map/, flow/, symbol/, file/, entry/
views/ one component per route
```
@@ -67,6 +68,29 @@ announce the project to a font CDN.
| `#/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
+13 -1
View File
@@ -8,9 +8,11 @@
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 } from './lib/router.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';
@@ -38,6 +40,10 @@
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');
});
});
@@ -103,6 +109,10 @@
event.preventDefault();
navigate(flowHref());
break;
case 'e':
event.preventDefault();
navigate(entryHref());
break;
case 'Backspace':
case '[':
event.preventDefault();
@@ -132,6 +142,8 @@
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}
+6
View File
@@ -76,6 +76,12 @@
<span class="mid">
<span class="nm">{item.name}</span>
</span>
{:else if item.type === 'entry'}
<KindGlyph kind={item.row.kind} />
<span class="mid" title={item.row.title}>
<span class="nm">{item.name}</span>
{#if item.meta}<span class="sig">{item.meta}</span>{/if}
</span>
{:else}
<KindGlyph kind={item.node.kind} />
<span class="mid">
+20 -2
View File
@@ -1,10 +1,18 @@
<script lang="ts">
import { router, mapHref, flowHref, symbolHref, fileHref, navigate } from '../lib/router.svelte';
import {
router,
mapHref,
flowHref,
entryHref,
symbolHref,
fileHref,
navigate,
} from '../lib/router.svelte';
import { trail } from '../lib/trail.svelte';
import { palette } from '../lib/palette.svelte';
import SearchPalette from './SearchPalette.svelte';
import type { PaletteItem } from '../lib/search-model';
import { walkTo } from '../lib/walk';
import { openEntryTarget, walkTo } from '../lib/walk';
import { live } from '../lib/live.svelte';
interface Props {
@@ -49,6 +57,15 @@
navigate(flowHref({ from: item.from, to: item.to }));
return;
}
// An entry-point row already knows where it goes — a handler, a file, a
// hub — and it is the one row type that can point at a FILE.
if (item.type === 'entry') {
if (!item.row.target) return;
palette.reset();
input?.blur();
openEntryTarget(item.row.target);
return;
}
const id = item.type === 'route' ? item.nodeId : item.id;
// A route whose handler never resolved to a node has nowhere to go; the
// row stays, because "this URL exists and we could not place it" is true.
@@ -149,6 +166,7 @@
</a>
<nav class="views" aria-label="Views">
<a href={entryHref()} class:active={view === 'entry'}>Entry points</a>
<a href={mapHref()} class:active={view === 'map'}>Map</a>
<a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
<a href={flowHref()} class:active={view === 'flow'}>Flow</a>
+239
View File
@@ -0,0 +1,239 @@
<!--
One section of the entry-points panel — routes, executable files, tests, hubs.
The file-group + row shapes are the Symbol view's caller rail (design spec
§3.2, `.filegroup` / `.row`), reused rather than re-invented: they are the
repo's established "a list of code, grouped by where it lives", and a second
visual language for the same idea is how a small app starts looking like two.
Every row does two things. Clicking it opens the code — a handler, a file, a
hub. The `Flow ›` chip beside it arms a flow FROM that symbol, which the panel
then completes with a second name. Rows that name no callable symbol carry no
chip: `/api/flow` searches by name, and a file has none the path finder can
look up.
-->
<script lang="ts">
import KindGlyph from '../KindGlyph.svelte';
import { fileHref } from '../../lib/router.svelte';
import type { EntryRow, EntrySection } from '../../lib/entry-model';
interface Props {
section: EntrySection;
/** The row currently armed as a flow's start, by row id. */
armed: string | null;
onopen: (row: EntryRow) => void;
onflow: (row: EntryRow) => void;
}
let { section, armed, onopen, onflow }: Props = $props();
</script>
<section class="sec" aria-labelledby={`entry-${section.id}`}>
<div class="sec-h">
<h3 id={`entry-${section.id}`}>{section.title}</h3>
<span class="meta">{section.meta}</span>
</div>
<p class="note">{section.note}</p>
{#each section.groups as group (group.path)}
<div class="filegroup">
<div class="fpath">
{#if group.file}
<a href={fileHref(group.file)} title={group.file}>{group.path}</a>
{:else}
<span title={group.path}>{group.path}</span>
{/if}
<b>{group.rows.length}</b>
</div>
{#each group.rows as row (row.id)}
<div class="row" class:armed={armed === row.id} class:stub={!row.target}>
<KindGlyph kind={row.kind} />
<div class="body">
<div class="line">
{#if row.target}
<button
type="button"
class="nm"
title={row.title}
data-entry-row={row.id}
onclick={() => onopen(row)}
>
{#if row.method}<span class="verb">{row.method}</span>{/if}{row.name}
</button>
{:else}
<span class="nm plain" title={row.title}>
{#if row.method}<span class="verb">{row.method}</span>{/if}{row.name}
</span>
{/if}
{#if row.flowFrom}
{@const label =
armed === null ? 'Flow ›' : armed === row.id ? 'Cancel' : '→ here'}
<button
type="button"
class="chip"
title={armed === null
? `Start a flow from ${row.flowFrom}`
: armed === row.id
? 'Stop drawing a flow from here'
: `Draw the path that ends at ${row.flowFrom}`}
data-entry-flow={row.id}
onclick={() => onflow(row)}>{label}</button
>
{/if}
</div>
<div class="meta">{row.meta}</div>
</div>
</div>
{/each}
</div>
{/each}
{#if section.shown < section.total}
<p class="note dim">
Showing {section.shown} of {section.floor ? 'at least ' : ''}{section.total} — the rest are in
the index, not on this list.
</p>
{/if}
</section>
<style>
.sec {
padding: 0 0 18px;
border-bottom: 1px solid var(--rule-faint);
}
.sec:last-child {
border-bottom: 0;
}
.sec-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 2px;
}
.sec-h h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.sec-h .meta {
color: var(--ink-3);
font-size: 11.5px;
}
.note {
margin: 0;
padding: 2px 14px 4px;
color: var(--ink-3);
font-size: 11.5px;
line-height: 1.4;
}
.note.dim {
color: var(--ink-4);
}
.filegroup {
padding: 10px 14px 4px;
}
.fpath {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
color: var(--ink-3);
font: 11px var(--mono);
}
.fpath a:hover {
color: var(--ink);
text-decoration: underline;
}
.fpath b {
color: var(--ink-2);
font-weight: 500;
}
.row {
position: relative;
display: grid;
grid-template-columns: 16px 1fr;
gap: 8px;
align-items: start;
margin: 0 -6px;
padding: 5px 6px 5px 4px;
border: 1px solid transparent;
}
.row:hover {
background: var(--press);
}
.row.armed {
border-color: var(--accent-line);
background: var(--accent-soft);
}
.body {
min-width: 0;
}
.line {
display: flex;
align-items: baseline;
gap: 8px;
}
.nm {
overflow: hidden;
min-width: 0;
color: var(--ink);
font: 12.5px var(--mono);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.nm:not(.plain) {
cursor: pointer;
}
.row.stub .nm {
color: var(--ink-2);
}
.verb {
margin-right: 6px;
color: var(--ink-2);
font-weight: 500;
}
.meta {
margin-top: 1px;
overflow: hidden;
color: var(--ink-3);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip {
flex: none;
padding: 0 4px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink-2);
font: 11px var(--mono);
}
.chip:hover {
border-color: var(--ink);
color: var(--ink);
}
</style>
+32 -3
View File
@@ -306,11 +306,22 @@ export interface WireNodeRefs {
/* ---------------------------------------------------------- entry points -- */
export interface WireEntryRoute {
/** The route node's name, verbatim: "POST /v1/users/{id}". */
url: string;
/** The verb, when the name leads with one. Null for a file-routed page. */
method: string | null;
/** The URL without the verb — the same string as `url` when there is none. */
path: string;
handler: string;
handlerKind: string;
/** Where the request is SERVED. */
file: string;
line: number;
handlerId: string | null;
/** Where the URL is REGISTERED — the router file, which is how routes group. */
routeFile: string;
routeLine: number;
routeId: string;
}
export interface WireEntryFile extends WireNodeRef {
@@ -326,11 +337,28 @@ export interface WireEntryHub extends WireNodeRef {
dependents: number;
}
export interface WireEntryTest extends WireNodeRef {
/** Distinct other files this test reaches — what it exercises. */
reaches: number;
/** References behind that reach. */
refs: number;
}
export interface WireEntryPoints {
routes: { routed: boolean; routeCount: number; items: WireEntryRoute[] };
/** `total` is a floor on both lists — the server counts what its scan saw. */
/** Frameworks the resolver detected — named in the Routes header. */
frameworks: string[];
routes: {
routed: boolean;
/** Every `route` node in the graph, resolved handler or not. */
routeCount: number;
items: WireList<WireEntryRoute>;
};
/** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
files: WireList<WireEntryFile>;
tests: WireList<WireEntryTest>;
hubs: WireList<WireEntryHub>;
index: { lastIndexedAt: number | null; files: number };
timing: { elapsedMs: number; cached: boolean };
}
export interface WireStats {
@@ -606,11 +634,12 @@ export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Pro
}
export function fetchEntryPoints(
opts: { limit?: number } = {},
opts: { limit?: number; routes?: number } = {},
signal?: AbortSignal
): Promise<WireEntryPoints> {
const params = new URLSearchParams();
if (opts.limit) params.set('limit', String(opts.limit));
if (opts.routes) params.set('routes', String(opts.routes));
const query = params.toString();
return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
}
+406
View File
@@ -0,0 +1,406 @@
/**
* What the entry-points panel decides, without a browser.
*
* `/api/entrypoints` answers four questions about where a project starts —
* routes, files that run something, tests, hubs — as four flat ranked lists.
* The panel draws them as file groups, because the first thing a reader wants
* from twenty routes is *which router registers them*, and from twelve
* executable files *which directory they live in*. That regrouping is the whole
* of this module: it is presentation, it needs no round-trip, and it is a pure
* function so it can be tested without a DOM.
*
* Two rules it keeps:
*
* - **A row that cannot be opened is not offered as if it could.** A route
* whose handler never resolved to a node still appears — "this URL exists and
* we could not place it" is true and worth saying — but it carries no target
* and the panel draws it as text.
* - **A row only offers a flow if it names a callable symbol.** `/api/flow`
* searches the graph by NAME, and a file has no name the path finder can
* look up, so a "start a flow here" affordance on a file row would be a
* button that always fails.
*
* Tested in `__tests__/ui-entry-model.test.ts`.
*/
import type {
WireEntryFile,
WireEntryHub,
WireEntryPoints,
WireEntryRoute,
WireEntryTest,
WireList,
} from './api';
import { basename, plural } from './symbol-model';
/* ---------------------------------------------------------------- shapes -- */
/** Where a row goes when it is clicked. */
export type EntryTarget =
| { type: 'symbol'; id: string; name: string; kind: string }
| { type: 'file'; path: string }
| null;
export interface EntryRow {
/** Stable across refetches — the panel keys on it. */
id: string;
/** The row's own name column: a URL, a basename, a symbol name. */
name: string;
/** The verb, drawn ahead of the name in the same mono. Routes only. */
method: string | null;
/** One line under the name: handler + `file:line`, counts, what it reaches. */
meta: string;
/** Glyph kind — a NodeKind string, or 'route'. */
kind: string;
target: EntryTarget;
/**
* The symbol name a flow would start from, when this row names one.
* Null on file rows: the path finder looks symbols up by name.
*/
flowFrom: string | null;
/** Hover text: the fuller truth the row had to shorten. */
title: string;
}
export interface EntryGroup {
/** The file or directory the rows share. */
path: string;
/** The file to open when the group heading is clicked, when there is one. */
file: string | null;
rows: EntryRow[];
}
export interface EntrySection {
id: 'routes' | 'files' | 'tests' | 'hubs';
title: string;
/** The header's right-hand meta: counts, and the framework when detected. */
meta: string;
/** A sentence saying what the section is derived from. */
note: string;
groups: EntryGroup[];
/** Rows drawn, and the real total behind them. */
shown: number;
total: number;
/** `total` is a lower bound the server could not tighten. */
floor: boolean;
}
export interface EntryPanel {
sections: EntrySection[];
/** Every row, in the order the sections draw them. */
rows: EntryRow[];
/** Nothing to show, and why. Null when there is something. */
empty: string | null;
}
/* -------------------------------------------------------------- grouping -- */
/** `src/bin/codegraph.ts` -> `src/bin`; a root file -> `project root`. */
export function directoryOf(path: string): string {
const cut = path.lastIndexOf('/');
return cut < 0 ? 'project root' : path.slice(0, cut);
}
/**
* Fold rows into groups, first-seen order.
*
* First-seen rather than alphabetical, so the ranking the server computed is
* still visible: the busiest router file, or the directory holding the highest
* ranked executable, leads the section.
*/
export function groupRows(
placed: ReadonlyArray<{ row: EntryRow; path: string; file: string | null }>
): EntryGroup[] {
const groups: EntryGroup[] = [];
const byPath = new Map<string, EntryGroup>();
for (const { row, path, file } of placed) {
let group = byPath.get(path);
if (!group) {
group = { path, file, rows: [] };
byPath.set(path, group);
groups.push(group);
}
group.rows.push(row);
}
return groups;
}
/* ------------------------------------------------------------------ rows -- */
export function routeRow(route: WireEntryRoute): EntryRow {
const where = `${basename(route.file)}:${route.line}`;
return {
id: `route:${route.routeId}`,
name: route.path,
method: route.method,
kind: 'route',
// The handler is the answer to "what serves this URL", so it leads the
// meta line; the file only says where to find it.
meta: route.handlerId ? `${route.handler} · ${where}` : `${route.handler} · not in the index`,
target: route.handlerId
? { type: 'symbol', id: route.handlerId, name: route.handler, kind: route.handlerKind }
: null,
flowFrom: route.handlerId ? route.handler : null,
title: `${route.url} → ${route.handler} (${route.file}:${route.line}), registered at ${route.routeFile}:${route.routeLine}`,
};
}
export function fileRow(file: WireEntryFile): EntryRow {
return {
id: `file:${file.file}`,
name: basename(file.file),
method: null,
kind: 'file',
meta: `${plural(file.calls, 'call')} at module level · reaches ${plural(file.reaches, 'file')}${
file.dependents === 0 ? ' · nothing imports it' : ''
}`,
target: { type: 'file', path: file.file },
flowFrom: null,
title: file.file,
};
}
export function testRow(test: WireEntryTest): EntryRow {
return {
id: `test:${test.file}`,
name: basename(test.file),
method: null,
kind: 'file',
meta: `exercises ${plural(test.reaches, 'file')} · ${plural(test.refs, 'reference')}`,
target: { type: 'file', path: test.file },
flowFrom: null,
title: test.file,
};
}
export function hubRow(hub: WireEntryHub): EntryRow {
return {
id: `hub:${hub.id}`,
name: hub.name,
method: null,
kind: hub.kind,
meta: `${plural(hub.dependents, 'dependent')} · ${basename(hub.file)}:${hub.line}`,
target: { type: 'symbol', id: hub.id, name: hub.name, kind: hub.kind },
flowFrom: hub.name,
title: `${hub.qualifiedName} — ${hub.file}:${hub.line}`,
};
}
/* ----------------------------------------------------------------- panel -- */
/** "42 of 208" when the list was cut, "42" when it was not. */
function countMeta(list: { shown: number; total: number }, floor: boolean): string {
if (list.shown >= list.total) return `${list.shown}`;
return `${list.shown} of ${floor ? 'at least ' : ''}${list.total}`;
}
/**
* "gin", "express and spring" — the frameworks behind a route list.
*
* Named because a route list is a claim about a framework's conventions; a
* reader who knows the app is Gin and sees "spring" learns something useful
* about the index rather than being quietly misled by it.
*/
export function frameworkPhrase(frameworks: readonly string[]): string {
if (frameworks.length === 0) return '';
if (frameworks.length === 1) return frameworks[0] as string;
if (frameworks.length === 2) return `${frameworks[0]} and ${frameworks[1]}`;
return `${frameworks.slice(0, -1).join(', ')} and ${frameworks[frameworks.length - 1]}`;
}
function section(
id: EntrySection['id'],
title: string,
note: string,
list: WireList<unknown>,
groups: EntryGroup[],
floor: boolean,
extraMeta = ''
): EntrySection {
const counts = countMeta(list, floor);
return {
id,
title,
meta: extraMeta ? `${counts} · ${extraMeta}` : counts,
note,
groups,
shown: list.shown,
total: list.total,
floor,
};
}
export function buildEntryPanel(entries: WireEntryPoints | null): EntryPanel {
if (!entries) return { sections: [], rows: [], empty: null };
const sections: EntrySection[] = [];
// A project with fewer than three resolvable routes is not a routed app, and
// the engine says so rather than half-answering. No Routes heading at all in
// that case — an empty box under a heading reads as a failure, and this is
// the ordinary shape of a library.
if (entries.routes.routed && entries.routes.items.items.length > 0) {
sections.push(
section(
'routes',
'Routes',
'A request from outside arrives here — the URL, and the symbol that serves it.',
entries.routes.items,
groupRows(
entries.routes.items.items.map((route) => ({
row: routeRow(route),
// Grouped by where the URL is REGISTERED, not by where it is
// served: a router file is the shape a reader already has in mind,
// and handlers scatter across a package.
path: route.routeFile,
file: route.routeFile,
}))
),
false,
frameworkPhrase(entries.frameworks)
)
);
}
if (entries.files.items.length > 0) {
sections.push(
section(
'files',
'Top-level files with calls',
'Statements at the top level of the file — a CLI, a worker entry, a script.',
entries.files,
groupRows(
entries.files.items.map((file) => ({
row: fileRow(file),
path: directoryOf(file.file),
file: null,
}))
),
true
)
);
}
if (entries.tests.items.length > 0) {
sections.push(
section(
'tests',
'Tests',
'What already exercises this code, widest reach first.',
entries.tests,
groupRows(
entries.tests.items.map((test) => ({
row: testRow(test),
path: directoryOf(test.file),
file: null,
}))
),
false
)
);
}
if (entries.hubs.items.length > 0) {
sections.push(
section(
'hubs',
'Most depended on',
'Not where the project starts — where a change radiates furthest.',
entries.hubs,
groupRows(
entries.hubs.items.map((hub) => ({
row: hubRow(hub),
path: hub.file,
file: hub.file,
}))
),
true
)
);
}
return {
sections,
rows: sections.flatMap((s) => s.groups.flatMap((g) => g.rows)),
empty:
sections.length === 0
? 'This index has no routes, no file that runs anything at module level, no test that reaches outside itself, and nothing depended on yet.'
: null,
};
}
/* --------------------------------------------------------------- palette -- */
/** Entry-point rows the palette shows under a typed query, ranked and capped. */
export interface EntryMatch {
row: EntryRow;
/** Which list it came from, for the row's location column. */
origin: 'route' | 'file' | 'test' | 'hub';
}
/**
* Entry points that mention what was typed.
*
* The palette already searches the graph, and route nodes, files and symbols
* all come back from that search — so what this adds is not the row but its
* CONTEXT: a `/api/search` hit on `POST /v1/payroll/cycles/{cycleID}/run` is a
* route node with no handler attached, and this one carries the handler, its
* file and line, and a target that opens the code rather than the URL.
*
* Matching is a plain case-insensitive substring over the text the row draws.
* Anything cleverer would rank differently from the search above it, and two
* different rankings of the same words in one panel is how a palette stops
* being predictable.
*/
export function matchEntries(
entries: WireEntryPoints | null,
query: string,
limit: number
): EntryMatch[] {
const needle = query.trim().toLowerCase();
if (!entries || needle === '') return [];
const pools: Array<[EntryMatch['origin'], EntryRow[]]> = [
['route', entries.routes.routed ? entries.routes.items.items.map(routeRow) : []],
['file', entries.files.items.map(fileRow)],
['test', entries.tests.items.map(testRow)],
['hub', entries.hubs.items.map(hubRow)],
];
const matches: EntryMatch[] = [];
for (const [origin, rows] of pools) {
for (const row of rows) {
if (matches.length >= limit) return matches;
const haystack = `${row.method ?? ''} ${row.name} ${row.meta} ${row.title}`.toLowerCase();
if (haystack.includes(needle)) matches.push({ row, origin });
}
}
return matches;
}
/** The location column for a palette entry row — where it came from. */
export function originLabel(origin: EntryMatch['origin']): string {
switch (origin) {
case 'route':
return 'route';
case 'file':
return 'runs at module level';
case 'test':
return 'test';
case 'hub':
return 'depended on';
}
}
/* ------------------------------------------------------------------ flow -- */
/**
* The href a flow between two named symbols opens at, or null when the pair is
* not a question. Same name twice has no path to draw, and `/api/flow` refuses
* it — better to disable the button than to navigate into a 400.
*/
export function flowPair(from: string, to: string): { from: string; to: string } | null {
const a = from.trim();
const b = to.trim();
if (!a || !b || a.toLowerCase() === b.toLowerCase()) return null;
return { from: a, to: b };
}
+49 -4
View File
@@ -35,6 +35,19 @@ const SEARCH_LIMIT = 40;
const ENTRY_LIMIT = 24;
export const PALETTE_ENTRY_ROWS = 6;
/**
* Route rows fetched.
*
* Separate from `ENTRY_LIMIT` because routes are the one list whose useful
* length is the project's, not the reader's: the panel groups them under their
* router files, where two hundred rows are still navigable, while two hundred
* "most depended on" symbols are a wall.
*/
const ENTRY_ROUTE_LIMIT = 200;
/** Entry-point rows the palette adds under a typed query. */
export const PALETTE_ENTRY_MATCHES = 6;
/**
* Milliseconds of quiet before a query is sent.
*
@@ -51,6 +64,9 @@ let loading = $state(false);
let failure = $state<string | null>(null);
let answers = $state<WireSearch[]>([]);
let entries = $state<WireEntryPoints | null>(null);
/** Null until the first attempt settles — the panel says "reading" until then. */
let entriesSettled = $state(false);
let entriesFailure = $state<string | null>(null);
let inflight: AbortController | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -61,14 +77,21 @@ let entriesInflight: Promise<void> | null = null;
function loadEntries(): Promise<void> {
if (entriesInflight) return entriesInflight;
entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT })
entriesInflight = fetchEntryPoints({ limit: ENTRY_LIMIT, routes: ENTRY_ROUTE_LIMIT })
.then((value) => {
entries = value;
entriesFailure = null;
})
.catch(() => {
.catch((cause: unknown) => {
// The palette still works without them; a failed "where do I start"
// should never stop someone from typing a name.
// should never stop someone from typing a name. The entry-points panel
// is the one screen that has nothing else to show, so the reason is
// kept rather than swallowed.
entries = null;
entriesFailure = cause instanceof Error ? cause.message : String(cause);
})
.finally(() => {
entriesSettled = true;
});
return entriesInflight;
}
@@ -122,7 +145,11 @@ function schedule(text: string): void {
/** The palette as it should be drawn right now. */
function current(): Palette {
if (query.trim() === '') return buildEntryPalette(entries, { perSection: PALETTE_ENTRY_ROWS });
return buildSearchPalette(answers, parseFlowQuery(query));
return buildSearchPalette(answers, parseFlowQuery(query), {
entries,
query,
entryRows: PALETTE_ENTRY_MATCHES,
});
}
export const palette = {
@@ -185,7 +212,25 @@ export const palette = {
},
/** Load the entry points without opening the panel (the empty screen wants them). */
ensureEntries: loadEntries,
/**
* Ask again, because the index moved.
*
* Entry points describe the index, so they are fetched once and kept — which
* means a sync would otherwise leave the resting palette, the empty screen
* and the entry-points panel all describing the graph as it was.
*/
reloadEntries(): Promise<void> {
entriesInflight = null;
return loadEntries();
},
get entries(): WireEntryPoints | null {
return entries;
},
/** False until the first fetch settles, however it settled. */
get entriesSettled(): boolean {
return entriesSettled;
},
get entriesFailure(): string | null {
return entriesFailure;
},
};
+8
View File
@@ -10,6 +10,7 @@
* #/file/<path> file view (?hl=<line>, ?src=1 for whole-file source)
* #/map module map (?root=&depth=&tests=1)
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
* #/entry entry points (where a flow starts)
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
* `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
@@ -40,6 +41,7 @@ export type Route =
/** An encoded trail, read as a flow. Same format the `t` param uses. */
trail: string | null;
}
| { view: 'entry' }
| { view: 'unknown'; path: string };
export type ViewName = Route['view'];
@@ -99,6 +101,8 @@ export function parseHash(hash: string): RouterLocation {
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
tests: params.get('tests') === '1',
};
} else if (head === 'entry' && rest.length === 0) {
route = { view: 'entry' };
} else if (head === 'flow' && rest.length === 0) {
// The question travels in the URL exactly as it was asked, so a flow can be
// linked in a review and reopen as the same path.
@@ -150,6 +154,10 @@ export function mapHref(
return `#/map${query ? `?${query}` : ''}`;
}
export function entryHref(): string {
return '#/entry';
}
export function flowHref(
opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
): string {
+50 -6
View File
@@ -18,6 +18,7 @@ import type {
WireSearch,
WireSearchResult,
} from './api';
import { matchEntries, originLabel, type EntryRow } from './entry-model';
import { basename, plural } from './symbol-model';
/* ------------------------------------------------------------ flow query -- */
@@ -60,7 +61,13 @@ export function parseFlowQuery(query: string): FlowQuery | null {
export type PaletteItem =
| { type: 'symbol'; id: string; node: WireNodeRef; name: string; meta: string; location: string }
| { type: 'route'; id: string; url: string; handler: string; location: string; nodeId: string | null }
| { type: 'flow'; id: string; from: string; to: string; name: string; meta: string; location: string };
| { type: 'flow'; id: string; from: string; to: string; name: string; meta: string; location: string }
/**
* An entry point that mentions what was typed. It carries the panel's own
* row, so a route here names its HANDLER — which is the thing a `/api/search`
* hit on the same URL cannot do.
*/
| { type: 'entry'; id: string; row: EntryRow; name: string; meta: string; location: string };
export interface PaletteSection {
/** Sentence-case caption, e.g. "Methods", "Files that run something". */
@@ -171,7 +178,8 @@ export function groupByKind(results: readonly WireSearchResult[]): PaletteSectio
export function buildSearchPalette(
answers: readonly WireSearch[],
flow: FlowQuery | null
flow: FlowQuery | null,
entryOpts: { entries: WireEntryPoints | null; query: string; entryRows: number } | null = null
): Palette {
const results =
answers.length > 1
@@ -198,6 +206,32 @@ export function buildSearchPalette(
],
});
}
// Entry points come LAST, under their own heading: they are context on rows
// the search above may already have found, and putting context above matches
// would push what was actually asked for off the panel. Rows whose target is
// already in the results are dropped — the same symbol twice under two
// headings makes the panel look like it is guessing.
if (entryOpts) {
const seen = new Set(results.map((result) => result.id));
const matches = matchEntries(entryOpts.entries, entryOpts.query, entryOpts.entryRows).filter(
(match) => !(match.row.target?.type === 'symbol' && seen.has(match.row.target.id))
);
if (matches.length > 0) {
sections.push({
title: 'Entry points',
note: 'Where a flow starts — routes, files that run something, tests.',
items: matches.map(({ row, origin }) => ({
type: 'entry' as const,
id: `entry:${row.id}`,
row,
name: row.method ? `${row.method} ${row.name}` : row.name,
meta: row.meta,
location: originLabel(origin),
})),
});
}
}
const items = sections.flatMap((section) => section.items);
const hint = flow
@@ -232,13 +266,13 @@ export function buildEntryPalette(
Number.isFinite(cap) ? items.slice(0, cap) : [...items];
const sections: PaletteSection[] = [];
if (entries.routes.routed && entries.routes.items.length > 0) {
if (entries.routes.routed && entries.routes.items.items.length > 0) {
sections.push({
title: 'Routes',
note: 'A request from outside arrives here.',
items: take(entries.routes.items).map((route) => ({
items: take(entries.routes.items.items).map((route) => ({
type: 'route' as const,
id: `route:${route.url}:${route.file}:${route.line}`,
id: `route:${route.routeId}`,
url: route.url,
handler: route.handler,
location: `${basename(route.file)}:${route.line}`,
@@ -257,6 +291,16 @@ export function buildEntryPalette(
});
}
if (entries.tests.items.length > 0) {
sections.push({
title: 'Tests',
note: 'What already exercises this code, widest reach first.',
items: take(entries.tests.items).map((test) =>
symbolItem(test, `exercises ${plural(test.reaches, 'file')}`)
),
});
}
if (entries.hubs.items.length > 0) {
sections.push({
title: 'Most depended on',
@@ -273,7 +317,7 @@ export function buildEntryPalette(
hint: null,
empty:
sections.length === 0
? 'This index has no routes, no file that runs anything, and nothing depended on yet.'
? 'This index has no routes, no file that runs anything, no test that reaches outside itself, and nothing depended on yet.'
: null,
};
}
+20 -1
View File
@@ -13,8 +13,9 @@
* link reproduces the walk rather than starting a fresh one at the same symbol.
*/
import { navigate, symbolHref } from './router.svelte';
import { fileHref, navigate, symbolHref } from './router.svelte';
import { encodeTrail, trail, type HopDirection } from './trail.svelte';
import type { EntryTarget } from './entry-model';
export interface WalkTarget {
id: string;
@@ -52,3 +53,21 @@ export function arrivedFrom(): { id: string; rail: 'left' | 'right' } | null {
if (current.dir === 'up') return { id: previous.id, rail: 'right' };
return null;
}
/**
* Open whatever an entry-point row points at.
*
* A file goes to the File view rather than to the file node's Symbol view —
* the outline is on both, but only the File view carries the import rails —
* and it does NOT join the trail: a trail is a path through calls, and "I
* opened a file" is not a call. A symbol is a `start` hop, like any other jump
* that nothing on screen was stepped through to reach.
*/
export function openEntryTarget(target: EntryTarget): void {
if (!target) return;
if (target.type === 'file') {
navigate(fileHref(target.path));
return;
}
walkTo({ id: target.id, name: target.name, kind: target.kind }, 'start');
}
+245
View File
@@ -0,0 +1,245 @@
<script lang="ts">
/**
* Entry points — where a project starts, and where a flow starts.
*
* Four lists, all derived from the graph rather than from a filename
* convention (see `src/ui-server/api/entrypoints.ts` for what each is derived
* from), regrouped by the file or directory their rows share.
*
* The second half of the screen is the flow: a row that names a callable
* symbol arms a flow from it, and the panel then wants one more name. That
* second name can be typed, or picked by arming another row — "how does
* `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks
* once both ends are on screen, which is the whole reason this list and the
* Flow strip belong on speaking terms.
*
* The payload is the palette's: one `/api/entrypoints` serves the search box
* at rest, the empty screen and this panel, so all three agree on the order.
*/
import EntrySection from '../components/entry/EntrySection.svelte';
import { palette } from '../lib/palette.svelte';
import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model';
import { flowHref, navigate } from '../lib/router.svelte';
import { openEntryTarget } from '../lib/walk';
interface Props {
project?: string | null;
}
let { project = null }: Props = $props();
$effect(() => {
void palette.ensureEntries();
});
let panel = $derived(buildEntryPanel(palette.entries));
/** The row a flow is being drawn from, and the name it will start at. */
let armed = $state<{ id: string; name: string } | null>(null);
let reaches = $state('');
let input: HTMLInputElement | null = $state(null);
// A refetch (the index moved) can retire the armed row. Dropping the arming
// is the honest response: the symbol it named may not be there any more.
$effect(() => {
const id = armed?.id;
if (id && !panel.rows.some((row) => row.id === id)) armed = null;
});
function open(row: EntryRow): void {
openEntryTarget(row.target);
}
function draw(from: string, to: string): void {
const pair = flowPair(from, to);
if (!pair) return;
armed = null;
reaches = '';
navigate(flowHref(pair));
}
function onflow(row: EntryRow): void {
if (!row.flowFrom) return;
if (armed === null) {
armed = { id: row.id, name: row.flowFrom };
reaches = '';
// The input is the faster path for anyone who already knows the other
// end; focusing it costs nothing to anyone who would rather click a row.
queueMicrotask(() => input?.focus());
return;
}
if (armed.id === row.id) {
armed = null;
return;
}
draw(armed.name, row.flowFrom);
}
function onkeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
armed = null;
}
}
</script>
<div class="scroll">
<div class="head">
<h2>Entry points</h2>
<p>
Where a flow starts{project ? ` in ${project}` : ''} — every list below is read out of the
graph, not guessed from a filename. Open a row to read the code, or use
<span class="chiplike">Flow ›</span> to draw the path from it to a second symbol.
</p>
</div>
{#if armed}
<div class="arming" role="group" aria-label="Draw a flow">
<span class="from">{armed.name}</span>
<span class="arrow" aria-hidden="true">→</span>
<input
bind:this={input}
bind:value={reaches}
{onkeydown}
type="text"
autocomplete="off"
spellcheck="false"
placeholder="a symbol it reaches"
aria-label={`The symbol ${armed.name} should reach`}
onkeypress={(event) => {
if (event.key === 'Enter' && armed) draw(armed.name, reaches);
}}
/>
<button
type="button"
class="go"
disabled={flowPair(armed.name, reaches) === null}
onclick={() => armed && draw(armed.name, reaches)}>Draw the flow</button
>
<button type="button" class="cancel" onclick={() => (armed = null)}>Cancel</button>
<span class="hint">or pick the other end with <span class="chiplike">→ here</span></span>
</div>
{/if}
{#if palette.entriesFailure}
<p class="state">Could not read the entry points — {palette.entriesFailure}</p>
{:else if !palette.entriesSettled}
<p class="state">Reading the graph…</p>
{:else if panel.empty}
<p class="state">{panel.empty}</p>
{:else}
<div class="sections">
{#each panel.sections as section (section.id)}
<EntrySection {section} armed={armed?.id ?? null} onopen={open} {onflow} />
{/each}
</div>
{/if}
</div>
<style>
.scroll {
height: 100%;
overflow: auto;
}
.head {
max-width: 760px;
padding: 26px 40px 6px;
}
.head h2 {
margin: 0 0 6px;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
}
.head p {
margin: 0;
color: var(--ink-2);
font-size: 13px;
line-height: 1.45;
}
.chiplike {
padding: 0 4px;
border: 1px solid var(--rule-soft);
color: var(--ink-2);
font: 11px var(--mono);
}
.arming {
position: sticky;
top: 0;
z-index: 4;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 12px 40px 0;
padding: 8px 12px;
border: 1px solid var(--accent-line);
background: var(--accent-soft);
}
.arming .from {
color: var(--ink);
font: 500 12.5px var(--mono);
}
.arming .arrow {
color: var(--ink-3);
}
.arming input {
width: 220px;
height: 26px;
padding: 0 8px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink);
font: 12.5px var(--mono);
}
.arming input:focus {
border-color: var(--ink);
outline: none;
}
.arming button {
height: 26px;
padding: 0 10px;
border: 1px solid var(--rule-soft);
background: var(--paper);
color: var(--ink-2);
font-size: 12px;
}
.arming button:hover:not(:disabled) {
border-color: var(--ink);
color: var(--ink);
}
.arming button:disabled {
color: var(--ink-4);
cursor: default;
}
.arming .hint {
color: var(--ink-3);
font-size: 11.5px;
}
.state {
max-width: 760px;
padding: 16px 40px 40px;
color: var(--ink-3);
font-size: 12.5px;
line-height: 1.5;
}
.sections {
max-width: 760px;
margin: 14px 40px 48px;
border: 1px solid var(--rule-soft);
}
</style>
+36 -7
View File
@@ -5,15 +5,19 @@
* Nothing selected is the normal first state of a viewer opened on a project
* nobody has read before, so it carries the same entry points the palette
* shows at rest, at full length: the routes a request arrives on, the files
* that run something at module level, and the symbols the most code depends
* on. Every one of them is derived from the graph — see
* `src/ui-server/api/entrypoints.ts` for what each is derived from.
* that run something at module level, the tests that exercise the most of the
* project, and the symbols the most code depends on. Every one of them is
* derived from the graph — see `src/ui-server/api/entrypoints.ts` for what
* each is derived from.
*
* The full-length version, with the same rows grouped by file and able to
* start a flow, is `#/entry` (`EntryView`); this screen links to it.
*/
import PaletteRows from '../components/PaletteRows.svelte';
import { palette } from '../lib/palette.svelte';
import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
import { fileHref, flowHref, navigate } from '../lib/router.svelte';
import { walkTo } from '../lib/walk';
import { entryHref, fileHref, flowHref, navigate } from '../lib/router.svelte';
import { openEntryTarget, walkTo } from '../lib/walk';
interface Props {
project?: string | null;
@@ -33,6 +37,10 @@
navigate(flowHref({ from: item.from, to: item.to }));
return;
}
if (item.type === 'entry') {
openEntryTarget(item.row.target);
return;
}
const id = item.type === 'route' ? item.nodeId : item.id;
if (!id) return;
// A file opens the File view — its outline plus the import rails. The
@@ -65,7 +73,10 @@
{#if entries.sections.length > 0}
<section class="entries" aria-label="Where to start">
<h3>Where to start</h3>
<div class="entries-h">
<h3>Where to start</h3>
<a href={entryHref()}>All entry points ›</a>
</div>
<div class="rows">
<PaletteRows palette={entries} onpick={pick} />
</div>
@@ -90,12 +101,30 @@
padding: 8px 40px 48px;
}
.entries-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.entries h3 {
margin: 0 0 8px;
margin: 0;
font-size: 14px;
font-weight: 600;
}
.entries-h a {
color: var(--ink-2);
font-size: 12px;
}
.entries-h a:hover {
color: var(--ink);
text-decoration: underline;
}
.rows {
border: 1px solid var(--rule-soft);
}