feat(ui): the Flow strip — how one symbol reaches another, one card per hop (CG-50)
Ask "how does execute reach getFile" in the search box and the viewer draws the call path between them, left to right, opening every card at the exact line that makes the next call. Dynamic-dispatch hops are dashed and name the site they were wired at; "Read as flow" turns a trail walked by hand into the same strip. The path finder is NOT new. `codegraph_explore` already leads its answers with the longest call chain among the symbols an agent named, and a viewer that drew a different path would get the two quoted against each other in a review. So the search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both callers ride it — same tokens, same overload rules, same synthesized edges. What stayed behind in `tools.ts` is the prose. A pinned from/to question is the same search with two options changed, because both ends being named is the evidence explore's one-unnamed-bridge cap stands in for: it bridges freely, keeps twelve candidates per endpoint instead of six (the CLI's own `main` sorts seventh of ten), and searches from both ends at once — identical paths to the one-way walk on twelve measured pairs, 3-6x faster. `/api/flow` is deliberately the one endpoint with no cache: its cards carry source read from disk, and a drift verdict changes without the index changing. Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to `resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to `renderStaticScene` crosses callback, react-render and jsx-child hops and lists exactly the hops `codegraph_explore` prints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6d0f60f32c
commit
62e0a89b0e
@@ -367,6 +367,79 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- flow strip -- */
|
||||
|
||||
export interface WireFlowEdge extends WireEdge {
|
||||
/** The link's label: "calls", "via callback · registered at file:line". */
|
||||
label: string;
|
||||
/** This hop reads callee → caller — the reader stepped UP into it. */
|
||||
upward: boolean;
|
||||
/** Confidence below 0.6: the link is dashed `2 3`. */
|
||||
uncertain: boolean;
|
||||
/** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
|
||||
synthesized: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowSource {
|
||||
file: string;
|
||||
language: string;
|
||||
from: number;
|
||||
to: number;
|
||||
/** Absent when `drift` — a mis-sliced window is worse than an empty card. */
|
||||
lines?: string[];
|
||||
highlight?: WireHighlight;
|
||||
drift: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The call site a card is opened at — the identifier drawn as an accent link. */
|
||||
export interface WireFlowCallRef {
|
||||
line: number;
|
||||
col: number | null;
|
||||
name: string;
|
||||
targetId: string;
|
||||
/** The link points back at the previous card, not on to the next one. */
|
||||
backwards: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowHop {
|
||||
node: WireNodeRef;
|
||||
/** The edge from the PREVIOUS hop into this one; null on the first. */
|
||||
edge: WireFlowEdge | null;
|
||||
callRef: WireFlowCallRef | null;
|
||||
source: WireFlowSource | null;
|
||||
}
|
||||
|
||||
export interface WireFlow {
|
||||
id: string;
|
||||
/** "execute → rowToFileRecord", for the header's flow picker. */
|
||||
label: string;
|
||||
hops: WireFlowHop[];
|
||||
}
|
||||
|
||||
export interface WireFlowAmbiguity {
|
||||
token: string;
|
||||
chosen: WireNodeRef | null;
|
||||
others: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlowPayload {
|
||||
query: {
|
||||
kind: 'directed' | 'symbols' | 'trail';
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
symbols: string[];
|
||||
};
|
||||
flows: WireFlow[];
|
||||
ambiguous: WireFlowAmbiguity[];
|
||||
/** Tokens that named nothing in this index. */
|
||||
unresolved: string[];
|
||||
/** Why there is no flow, when there is none. */
|
||||
reason: string | null;
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- the map -- */
|
||||
|
||||
export interface WireMapModule {
|
||||
@@ -487,3 +560,24 @@ export function fetchMap(
|
||||
const query = params.toString();
|
||||
return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* A flow. Exactly one of the three shapes is sent:
|
||||
*
|
||||
* - `{ from, to }` — "how does X reach Y", from the search box.
|
||||
* - `{ symbols }` — `codegraph_explore`'s own question, verbatim.
|
||||
* - `{ trail }` — the hops the reader walked, as `<dir><id>` strings. Each one
|
||||
* is its own parameter, because a node id can be a file path and a file path
|
||||
* can contain a comma.
|
||||
*/
|
||||
export function fetchFlow(
|
||||
spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] },
|
||||
signal?: AbortSignal
|
||||
): Promise<WireFlowPayload> {
|
||||
const params = new URLSearchParams();
|
||||
if (spec.from) params.set('from', spec.from);
|
||||
if (spec.to) params.set('to', spec.to);
|
||||
if (spec.symbols) params.set('symbols', spec.symbols);
|
||||
for (const hop of spec.trail ?? []) params.append('hop', hop);
|
||||
return getJson<WireFlowPayload>(`api/flow?${params}`, signal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* The Flow strip's geometry, without a browser.
|
||||
*
|
||||
* The strip reads left to right: one card per hop, opened at the line that
|
||||
* makes the next call, linked by an 86px connector carrying the edge. That is a
|
||||
* straight line for one path — but two paths that share endpoints are one
|
||||
* picture, not two, so the layout is a small DAG over the union of whatever
|
||||
* flows are on screen, and a single chain is just the DAG with one node per
|
||||
* column.
|
||||
*
|
||||
* Two rules make it deterministic, which is the whole point of not using a
|
||||
* physics layout (design spec §1):
|
||||
*
|
||||
* - **A card's column is its longest distance from a start.** Two routes that
|
||||
* rejoin therefore rejoin in the same column, and a card never sits left of
|
||||
* something that calls it.
|
||||
* - **A card's height is computed, not measured.** The number of source lines
|
||||
* is known before anything renders, so the rows can be packed without waiting
|
||||
* for a `ResizeObserver` — and the card's CSS pins the same height, so the
|
||||
* arrows land where the arithmetic said they would. The File view's outline
|
||||
* works the same way and for the same reason.
|
||||
*
|
||||
* Tested in `__tests__/ui-flow-model.test.ts`.
|
||||
*/
|
||||
|
||||
import type { WireFlow, WireFlowEdge, WireFlowHop } from './api';
|
||||
|
||||
/* ------------------------------------------------------------ dimensions -- */
|
||||
|
||||
/** Card width (design spec §3.5). */
|
||||
export const CARD_WIDTH = 380;
|
||||
/** Connector width between two cards, when the label fits inside it. */
|
||||
export const LINK_WIDTH = 86;
|
||||
/** Distance between two columns' left edges, for a link with an ordinary label. */
|
||||
export const COLUMN_PITCH = CARD_WIDTH + LINK_WIDTH;
|
||||
|
||||
/**
|
||||
* Advance of IBM Plex Mono at the 11px a connector label is set in, and the
|
||||
* clear space kept either side of the longest line.
|
||||
*
|
||||
* A gap only ever GROWS past {@link LINK_WIDTH}: 86px holds `calls` and
|
||||
* `line 2029` comfortably, but a synthesized hop's `registered at App.tsx:3764`
|
||||
* is twenty-six characters, and at a fixed pitch it ran underneath the cards on
|
||||
* both sides of it — on excalidraw's `mutateElement` flow, over the source of
|
||||
* the very card the label was explaining. The label is the evidence for a hop
|
||||
* nobody can see in the source, so the picture makes room for it.
|
||||
*/
|
||||
const LABEL_CHAR_WIDTH = 6.65;
|
||||
const LABEL_PAD = 18;
|
||||
|
||||
/** Card header: `10px 12px 6px` padding around one 18px row, plus a rule. */
|
||||
export const HEADER_HEIGHT = 35;
|
||||
/** Source window: `12px/19px` mono with 6px of padding above and below. */
|
||||
export const CODE_LINE_HEIGHT = 19;
|
||||
export const CODE_PADDING = 12;
|
||||
/** A card with no source still says why, in one line of the same height. */
|
||||
export const NO_SOURCE_HEIGHT = CODE_LINE_HEIGHT + CODE_PADDING;
|
||||
/** Clear space between two cards stacked in one column. */
|
||||
export const ROW_GAP = 24;
|
||||
/** Canvas padding around the whole strip. */
|
||||
export const PADDING = 32;
|
||||
|
||||
/** Exact rendered height of a card, which its CSS then pins. */
|
||||
export function cardHeight(hop: WireFlowHop): number {
|
||||
const lines = hop.source?.lines?.length ?? 0;
|
||||
const body = lines > 0 ? lines * CODE_LINE_HEIGHT + CODE_PADDING : NO_SOURCE_HEIGHT;
|
||||
return HEADER_HEIGHT + body;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- model -- */
|
||||
|
||||
export interface FlowCardLayout {
|
||||
/** Node id — unique in the DAG even when two flows both contain it. */
|
||||
id: string;
|
||||
hop: WireFlowHop;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
column: number;
|
||||
/** Flows this card belongs to, by flow id — what dims when one is picked. */
|
||||
flows: string[];
|
||||
/** Position in the ACTIVE flow, or -1 when it is not on it. */
|
||||
step: number;
|
||||
}
|
||||
|
||||
export interface FlowLinkLayout {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
edge: WireFlowEdge;
|
||||
/** Flows this link belongs to. */
|
||||
flows: string[];
|
||||
/** The full label, for the connector's tooltip. */
|
||||
label: string;
|
||||
/**
|
||||
* The label broken into short centred lines, longest path segments shortened
|
||||
* to a basename. Eighty-six pixels is about eleven monospace characters, so a
|
||||
* synthesized hop's `via interface impl / registered at payroll.go:37` has to
|
||||
* stack rather than run over both cards it sits between.
|
||||
*/
|
||||
labelLines: string[];
|
||||
/** `line 2029` — drawn under the connector, when the edge recorded one. */
|
||||
lineLabel: string | null;
|
||||
/** SVG dasharray, or null for a solid line. */
|
||||
dash: string | null;
|
||||
}
|
||||
|
||||
export interface FlowLayout {
|
||||
cards: FlowCardLayout[];
|
||||
links: FlowLinkLayout[];
|
||||
width: number;
|
||||
height: number;
|
||||
/** Longest chain on screen, in cards. */
|
||||
columns: number;
|
||||
/** Connector width after each column — {@link LINK_WIDTH} unless a label needed more. */
|
||||
gaps: number[];
|
||||
}
|
||||
|
||||
/** Dash pattern for a link (design spec §3.5). Heuristic wins over uncertain. */
|
||||
export function dashFor(edge: WireFlowEdge): string | null {
|
||||
if (edge.synthesized) return '5 3';
|
||||
if (edge.uncertain) return '2 3';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Longest a connector label line may be before it is cut. */
|
||||
export const LABEL_MAX_CHARS = 26;
|
||||
|
||||
/** `src/a/b/thing.go:37` reads as `thing.go:37` under an 86px connector. */
|
||||
function shortenSites(text: string): string {
|
||||
return text.replace(/[\w.@$/\\-]+[/\\]([\w.$-]+:\d+)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* The label, stacked. `\u00b7`-separated clauses become their own lines, and
|
||||
* anything still too long is cut with an ellipsis — the connector's tooltip
|
||||
* carries the untruncated text.
|
||||
*/
|
||||
export function labelLinesFor(edge: WireFlowEdge): string[] {
|
||||
return shortenSites(edge.label)
|
||||
.split(' \u00b7 ')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) =>
|
||||
part.length > LABEL_MAX_CHARS ? `${part.slice(0, LABEL_MAX_CHARS - 1)}\u2026` : part
|
||||
);
|
||||
}
|
||||
|
||||
/** `line 2029`, or null when the edge carries no line. */
|
||||
export function lineLabelFor(edge: WireFlowEdge): string | null {
|
||||
return typeof edge.line === 'number' && edge.line > 0 ? `line ${edge.line}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out the union of `flows`, highlighting `activeId`.
|
||||
*
|
||||
* Passing one flow gives a single row of cards; passing several gives the DAG
|
||||
* where they share hops. The active flow decides the vertical order — it is
|
||||
* drawn along the top of its columns — so picking a flow never re-sorts the
|
||||
* picture underneath the reader.
|
||||
*/
|
||||
export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | null): FlowLayout {
|
||||
const active = flows.find((f) => f.id === activeId) ?? flows[0] ?? null;
|
||||
const activeSteps = new Map<string, number>();
|
||||
active?.hops.forEach((hop, index) => activeSteps.set(hop.node.id, index));
|
||||
|
||||
// ---- collect nodes and edges over every flow on screen -------------------
|
||||
const cards = new Map<string, { hop: WireFlowHop; flows: string[]; order: number }>();
|
||||
const links = new Map<
|
||||
string,
|
||||
{ source: string; target: string; edge: WireFlowEdge; flows: string[] }
|
||||
>();
|
||||
const successors = new Map<string, Set<string>>();
|
||||
const indegree = new Map<string, number>();
|
||||
|
||||
let order = 0;
|
||||
for (const flow of flows) {
|
||||
for (let i = 0; i < flow.hops.length; i++) {
|
||||
const hop = flow.hops[i] as WireFlowHop;
|
||||
const id = hop.node.id;
|
||||
const existing = cards.get(id);
|
||||
if (existing) {
|
||||
if (!existing.flows.includes(flow.id)) existing.flows.push(flow.id);
|
||||
} else {
|
||||
cards.set(id, { hop, flows: [flow.id], order: order++ });
|
||||
indegree.set(id, 0);
|
||||
successors.set(id, new Set());
|
||||
}
|
||||
|
||||
const previous = flow.hops[i - 1];
|
||||
if (!previous || hop.edge === null) continue;
|
||||
// An upward hop is the same edge read backwards; the ARROW still points
|
||||
// the way the reader travelled, which is what the strip is describing.
|
||||
const from = previous.node.id;
|
||||
const key = `${from} ${id}`;
|
||||
const link = links.get(key);
|
||||
if (link) {
|
||||
if (!link.flows.includes(flow.id)) link.flows.push(flow.id);
|
||||
continue;
|
||||
}
|
||||
links.set(key, { source: from, target: id, edge: hop.edge, flows: [flow.id] });
|
||||
const outs = successors.get(from);
|
||||
if (outs && !outs.has(id)) {
|
||||
outs.add(id);
|
||||
indegree.set(id, (indegree.get(id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cards.size === 0) {
|
||||
return { cards: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
|
||||
}
|
||||
|
||||
// ---- column = longest distance from a start -----------------------------
|
||||
const column = new Map<string, number>();
|
||||
for (const id of cards.keys()) column.set(id, 0);
|
||||
// Kahn order, so a node is placed only after everything that reaches it.
|
||||
const pending = new Map(indegree);
|
||||
const queue = [...cards.keys()].filter((id) => (pending.get(id) ?? 0) === 0);
|
||||
const settled = new Set<string>();
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift() as string;
|
||||
settled.add(id);
|
||||
for (const next of successors.get(id) ?? []) {
|
||||
column.set(next, Math.max(column.get(next) ?? 0, (column.get(id) ?? 0) + 1));
|
||||
const left = (pending.get(next) ?? 0) - 1;
|
||||
pending.set(next, left);
|
||||
if (left === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
// A cycle (a flow that calls back into itself) leaves nodes unsettled. They
|
||||
// are still real hops, so they go one column past whatever reached them
|
||||
// rather than disappearing.
|
||||
for (const id of cards.keys()) {
|
||||
if (settled.has(id)) continue;
|
||||
let best = 0;
|
||||
for (const [from, outs] of successors) {
|
||||
if (outs.has(id)) best = Math.max(best, (column.get(from) ?? 0) + 1);
|
||||
}
|
||||
column.set(id, best);
|
||||
}
|
||||
|
||||
// ---- pack each column, active flow first --------------------------------
|
||||
const byColumn = new Map<number, string[]>();
|
||||
for (const id of cards.keys()) {
|
||||
const c = column.get(id) ?? 0;
|
||||
const list = byColumn.get(c);
|
||||
if (list) list.push(id);
|
||||
else byColumn.set(c, [id]);
|
||||
}
|
||||
for (const list of byColumn.values()) {
|
||||
list.sort((a, b) => {
|
||||
const onA = activeSteps.has(a) ? 0 : 1;
|
||||
const onB = activeSteps.has(b) ? 0 : 1;
|
||||
if (onA !== onB) return onA - onB;
|
||||
return (cards.get(a)?.order ?? 0) - (cards.get(b)?.order ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
const columns = Math.max(...byColumn.keys()) + 1;
|
||||
|
||||
// Each gap is wide enough for the widest label that crosses it. Labels are
|
||||
// built here rather than in the render pass because the geometry depends on
|
||||
// them — see LABEL_CHAR_WIDTH.
|
||||
const labelled = [...links.entries()].map(([key, link]) => ({
|
||||
key,
|
||||
link,
|
||||
lines: labelLinesFor(link.edge),
|
||||
lineLabel: lineLabelFor(link.edge),
|
||||
}));
|
||||
const gaps = Array.from({ length: Math.max(0, columns - 1) }, () => LINK_WIDTH);
|
||||
for (const { link, lines, lineLabel } of labelled) {
|
||||
const from = column.get(link.source) ?? 0;
|
||||
if (from < 0 || from >= gaps.length) continue;
|
||||
const widest = Math.max(0, ...lines.map((l) => l.length), lineLabel?.length ?? 0);
|
||||
gaps[from] = Math.max(gaps[from] as number, Math.ceil(widest * LABEL_CHAR_WIDTH) + LABEL_PAD);
|
||||
}
|
||||
const columnX: number[] = [PADDING];
|
||||
for (let c = 1; c < columns; c++) {
|
||||
columnX[c] = (columnX[c - 1] as number) + CARD_WIDTH + (gaps[c - 1] as number);
|
||||
}
|
||||
|
||||
const heights = new Map<string, number>();
|
||||
for (const [id, card] of cards) heights.set(id, cardHeight(card.hop));
|
||||
|
||||
// Rows are centred on the tallest column, so a one-card column sits opposite
|
||||
// the middle of a two-card one instead of hugging the top of the canvas.
|
||||
const columnHeights = new Map<number, number>();
|
||||
for (const [c, list] of byColumn) {
|
||||
columnHeights.set(
|
||||
c,
|
||||
list.reduce((sum, id) => sum + (heights.get(id) ?? 0), 0) + ROW_GAP * (list.length - 1)
|
||||
);
|
||||
}
|
||||
const tallest = Math.max(...columnHeights.values());
|
||||
|
||||
const laidOut = new Map<string, FlowCardLayout>();
|
||||
for (const [c, list] of byColumn) {
|
||||
let y = PADDING + (tallest - (columnHeights.get(c) ?? 0)) / 2;
|
||||
for (const id of list) {
|
||||
const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
|
||||
const height = heights.get(id) ?? 0;
|
||||
laidOut.set(id, {
|
||||
id,
|
||||
hop: card.hop,
|
||||
x: columnX[c] as number,
|
||||
y,
|
||||
width: CARD_WIDTH,
|
||||
height,
|
||||
column: c,
|
||||
flows: card.flows,
|
||||
step: activeSteps.get(id) ?? -1,
|
||||
});
|
||||
y += height + ROW_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cards: [...laidOut.values()].sort((a, b) => a.column - b.column || a.y - b.y),
|
||||
links: labelled.map(({ link, lines, lineLabel }) => ({
|
||||
id: `${link.source}->${link.target}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
edge: link.edge,
|
||||
flows: link.flows,
|
||||
label: link.edge.label,
|
||||
labelLines: lines,
|
||||
lineLabel,
|
||||
dash: dashFor(link.edge),
|
||||
})),
|
||||
width: (columnX[columns - 1] as number) + CARD_WIDTH + PADDING,
|
||||
height: PADDING * 2 + tallest,
|
||||
columns,
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* #/s/<id> symbol view (?hl=<line> highlights a line, ?t=<trail>)
|
||||
* #/file/<path> file view (?hl=<line>)
|
||||
* #/map module map (?root=&depth=&tests=1)
|
||||
* #/flow[/<key>] flow strip — reserved, phase 2
|
||||
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
|
||||
*
|
||||
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
|
||||
* `<kind>:<relative/path>` (see src/extraction/tree-sitter-helpers.ts), so
|
||||
@@ -24,7 +24,16 @@ export type Route =
|
||||
| { view: 'symbol'; id: string; line: number | null }
|
||||
| { view: 'file'; path: string; line: number | null }
|
||||
| { view: 'map'; root: string | null; depth: number; tests: boolean }
|
||||
| { view: 'flow'; key: string | null }
|
||||
| {
|
||||
view: 'flow';
|
||||
/** "how does X reach Y" — both ends pinned. */
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
/** An explore-shaped bag of names, comma or space separated. */
|
||||
symbols: string | null;
|
||||
/** An encoded trail, read as a flow. Same format the `t` param uses. */
|
||||
trail: string | null;
|
||||
}
|
||||
| { view: 'unknown'; path: string };
|
||||
|
||||
export type ViewName = Route['view'];
|
||||
@@ -84,8 +93,16 @@ export function parseHash(hash: string): RouterLocation {
|
||||
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
|
||||
tests: params.get('tests') === '1',
|
||||
};
|
||||
} else if (head === 'flow') {
|
||||
route = { view: 'flow', key: rest.length > 0 ? rest.join('/') : null };
|
||||
} 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.
|
||||
route = {
|
||||
view: 'flow',
|
||||
from: params.get('from'),
|
||||
to: params.get('to'),
|
||||
symbols: params.get('symbols'),
|
||||
trail: params.get('t'),
|
||||
};
|
||||
} else {
|
||||
route = { view: 'unknown', path: pathPart };
|
||||
}
|
||||
@@ -119,8 +136,18 @@ export function mapHref(
|
||||
return `#/map${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function flowHref(key?: string): string {
|
||||
return key ? `#/flow/${encodePath(key)}` : '#/flow';
|
||||
export function flowHref(
|
||||
opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.from) params.set('from', opts.from);
|
||||
if (opts.to) params.set('to', opts.to);
|
||||
if (opts.symbols) params.set('symbols', opts.symbols);
|
||||
// `t`, not `trail`: the trail already travels under that name everywhere
|
||||
// else, and a flow read from one is the same walk under a different lens.
|
||||
if (opts.trail) params.set('t', opts.trail);
|
||||
const query = params.toString();
|
||||
return `#/flow${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
/* ---------- the live route ---------- */
|
||||
|
||||
@@ -30,10 +30,11 @@ export interface FlowQuery {
|
||||
/**
|
||||
* "how does X reach Y", "X -> Y", "X → Y".
|
||||
*
|
||||
* Phase 1 has no Flow view to send this to, but the question is worth
|
||||
* recognising anyway: someone who types it gets both endpoints looked up
|
||||
* instead of a search for the whole sentence, which matches nothing. CG-50
|
||||
* turns the same parse into a computed path.
|
||||
* The parse drives two things: the palette's first row, which opens the Flow
|
||||
* strip for exactly this pair, and the search underneath it, which looks up
|
||||
* both endpoints rather than searching the whole sentence (which matches
|
||||
* nothing). Both are useful — the flow answers the question, the endpoints let
|
||||
* a reader who spelled a name wrong see what they actually named.
|
||||
*/
|
||||
const FLOW_SENTENCE =
|
||||
/^\s*(?:how\s+(?:does|do|would|can)\s+)?([\w$.]+)\s+(?:reach|reaches|call|calls|hit|hits|get\s+to|end\s+up\s+(?:in|at))\s+([\w$.]+)\s*\??\s*$/i;
|
||||
@@ -58,7 +59,8 @@ 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: '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 };
|
||||
|
||||
export interface PaletteSection {
|
||||
/** Sentence-case caption, e.g. "Methods", "Files that run something". */
|
||||
@@ -177,10 +179,29 @@ export function buildSearchPalette(
|
||||
: answers[0]?.results.items ?? [];
|
||||
|
||||
const sections = groupByKind(results);
|
||||
// The flow row goes FIRST, so Enter opens the path: someone who typed
|
||||
// "how does X reach Y" asked for the path, not for a list of symbols.
|
||||
if (flow) {
|
||||
sections.unshift({
|
||||
title: 'Flow',
|
||||
note: 'The call path between them, one card per hop.',
|
||||
items: [
|
||||
{
|
||||
type: 'flow',
|
||||
id: `flow:${flow.from}:${flow.to}`,
|
||||
from: flow.from,
|
||||
to: flow.to,
|
||||
name: `${flow.from} → ${flow.to}`,
|
||||
meta: '',
|
||||
location: 'read as a flow',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const items = sections.flatMap((section) => section.items);
|
||||
|
||||
const hint = flow
|
||||
? `Reading the path between two symbols arrives with the Flow view. Here is what ${flow.from} and ${flow.to} name.`
|
||||
? `Reading the path from ${flow.from} to ${flow.to}. Below it, what each name matches.`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user