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:
co-authored by
Claude Opus 5
parent
dc7f1e590e
commit
94f4e287e6
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user