FlowView.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. <!--
  2. The Flow strip (`#/flow`, design spec §3.5): how one symbol reaches another,
  3. as one card per hop, each opened at the line that makes the next call.
  4. The path is not computed here and is not computed by the server either — it
  5. comes from `resolveNamedSymbolFlow`, the search `codegraph_explore` leads its
  6. answers with. That is deliberate: a viewer that drew a different path from the
  7. one the MCP tool describes would get the two quoted against each other in a
  8. review, and one of them would be wrong.
  9. Svelte Flow draws it, for pan, zoom and fit and nothing else: positions come
  10. from `buildFlowLayout`, the flow picker is local state, and nothing is
  11. draggable. Clicking a card opens the Symbol view with the trail set to the
  12. path so far, so the strip hands the reader off to the view that goes deep.
  13. -->
  14. <script lang="ts">
  15. import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
  16. import '@xyflow/svelte/dist/style.css';
  17. import FlowCard from '../components/flow/FlowCard.svelte';
  18. import FlowLink from '../components/flow/FlowLink.svelte';
  19. import FlowEndCap from '../components/flow/FlowEndCap.svelte';
  20. import ExportButtons from '../components/ExportButtons.svelte';
  21. import { exportFilename, flowSvg } from '../lib/export-svg';
  22. import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
  23. import { live } from '../lib/live.svelte';
  24. import { navigate, symbolHref } from '../lib/router.svelte';
  25. import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte';
  26. import { decodeTrail } from '../lib/trail-codec';
  27. import { buildFlowLayout, type FlowCardLayout, type FlowLayout } from '../lib/flow-model';
  28. import { basename } from '../lib/symbol-model';
  29. interface Props {
  30. from: string | null;
  31. to: string | null;
  32. symbols: string | null;
  33. /** An encoded trail, when the flow is the reader's own walk. */
  34. trailParam: string | null;
  35. }
  36. let { from, to, symbols, trailParam }: Props = $props();
  37. let payload = $state<WireFlowPayload | null>(null);
  38. let error = $state<string | null>(null);
  39. let loading = $state(true);
  40. let picked = $state<string | null>(null);
  41. /** True when the picker is on "All paths" — the union is drawn as a DAG. */
  42. let showAll = $state(false);
  43. const ALL = 'all-paths';
  44. /**
  45. * The strip opens at 1:1, top left — it never fits itself to the window.
  46. *
  47. * Fitting an eight-hop flow into a laptop's width lands at about 0.38 zoom,
  48. * which is a picture of eight grey rectangles: the source inside them is the
  49. * answer, and source you cannot read is not an answer. So the reader arrives
  50. * at the first card, full size, and pans. The Controls' fit button is still
  51. * there for anyone who wants the shape rather than the code.
  52. */
  53. const START_VIEWPORT = { x: 0, y: 0, zoom: 1 };
  54. const nodeTypes = { flow: FlowCard, cap: FlowEndCap };
  55. const edgeTypes = { flow: FlowLink };
  56. /** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
  57. const trailHops = $derived<TrailHop[]>(trailParam ? decodeTrail(trailParam) : []);
  58. $effect(() => {
  59. const spec = trailParam
  60. ? { trail: trailHops.map((h) => `${h.dir === 'start' ? 's' : h.dir === 'up' ? 'u' : 'd'}${h.id}`) }
  61. : symbols
  62. ? { symbols }
  63. : { from: from ?? '', to: to ?? '' };
  64. if (!trailParam && !symbols && !(from && to)) {
  65. payload = null;
  66. loading = false;
  67. error = null;
  68. return;
  69. }
  70. // Re-run when the index moves: a path is a walk over edges that a sync can
  71. // add, remove or re-route, and a strip drawn from the previous graph would
  72. // disagree with `codegraph_explore` about the same question.
  73. void live.indexTick;
  74. const controller = new AbortController();
  75. loading = true;
  76. error = null;
  77. const keep = picked;
  78. fetchFlow(spec, controller.signal)
  79. .then((next) => {
  80. payload = next;
  81. // A refresh keeps the reader's chosen path when it survived the sync.
  82. picked = next.flows.some((f) => f.id === keep) ? keep : (next.flows[0]?.id ?? null);
  83. loading = false;
  84. })
  85. .catch((err: unknown) => {
  86. if (controller.signal.aborted) return;
  87. error = err instanceof Error ? err.message : String(err);
  88. loading = false;
  89. });
  90. return () => controller.abort();
  91. });
  92. const flows = $derived<WireFlow[]>(payload?.flows ?? []);
  93. const shown = $derived<WireFlow[]>(
  94. showAll ? flows : flows.filter((f) => f.id === picked).slice(0, 1)
  95. );
  96. const layout = $derived<FlowLayout | null>(
  97. shown.length === 0 ? null : buildFlowLayout(showAll ? flows : shown, picked)
  98. );
  99. const activeFlow = $derived(flows.find((f) => f.id === picked) ?? flows[0] ?? null);
  100. const nodes = $derived.by<Node[]>(() => {
  101. if (layout === null) return [];
  102. const caps: Node[] = layout.endCaps.map((cap) => ({
  103. id: cap.id,
  104. type: 'cap',
  105. position: { x: cap.x, y: cap.y },
  106. draggable: false,
  107. selectable: false,
  108. connectable: false,
  109. data: {
  110. cap,
  111. dimmed: showAll && picked !== null && !cap.flows.includes(picked),
  112. onOpen: openNode,
  113. },
  114. }));
  115. // Caps first, so a card that overlaps one paints on top of it.
  116. return [
  117. ...caps,
  118. ...layout.cards.map((card) => ({
  119. id: card.id,
  120. type: 'flow',
  121. position: { x: card.x, y: card.y },
  122. draggable: false,
  123. selectable: false,
  124. connectable: false,
  125. data: {
  126. // The accent border marks the picked path, and only means something
  127. // when there is more than one on screen. A single flow whose every
  128. // card is accented has said nothing.
  129. card,
  130. current: showAll && card.step >= 0,
  131. dimmed: showAll && card.step < 0,
  132. onOpen: openCard,
  133. onFollow: followCard,
  134. },
  135. })),
  136. ];
  137. });
  138. const edges = $derived.by<Edge[]>(() => {
  139. if (layout === null) return [];
  140. return layout.links.map((link) => ({
  141. id: link.id,
  142. source: link.source,
  143. target: link.target,
  144. sourceHandle: 'out',
  145. targetHandle: 'in',
  146. type: 'flow',
  147. selectable: false,
  148. deletable: false,
  149. data: { link, dimmed: showAll && picked !== null && !link.flows.includes(picked) },
  150. }));
  151. });
  152. /**
  153. * Open a card in the Symbol view with the trail set to the path so far.
  154. *
  155. * The prefix, not the whole flow: the reader is standing at that hop, and a
  156. * trail that ran on past them would claim a walk they had not taken.
  157. */
  158. function openCard(card: FlowCardLayout): void {
  159. const hops = activeFlow?.hops ?? [];
  160. const at = hops.findIndex((hop) => hop.node.id === card.id);
  161. const prefix = at >= 0 ? hops.slice(0, at + 1) : [];
  162. trail.clear();
  163. prefix.forEach((hop, index) =>
  164. trail.push({
  165. id: hop.node.id,
  166. name: hop.node.name,
  167. kind: hop.node.kind,
  168. dir: index === 0 ? 'start' : hop.edge?.upward ? 'up' : 'down',
  169. })
  170. );
  171. if (prefix.length === 0) {
  172. trail.push({ id: card.id, name: card.hop.node.name, kind: card.hop.node.kind, dir: 'start' });
  173. }
  174. navigate(
  175. symbolHref(card.id, {
  176. trail: encodeTrail(trail.hops),
  177. ...(card.hop.callRef ? { line: card.hop.callRef.line } : {}),
  178. })
  179. );
  180. }
  181. /**
  182. * A row on the end cap: a candidate runtime target, or a continuation the
  183. * search refused to follow.
  184. *
  185. * It opens as a fresh start rather than as another hop, because neither is a
  186. * call the graph recorded — pushing one onto the trail would draw a step
  187. * nobody took. That is the whole reason the cap exists.
  188. */
  189. function openNode(nodeId: string): void {
  190. trail.clear();
  191. navigate(symbolHref(nodeId));
  192. }
  193. /** The accent link inside a card: step to the symbol it names. */
  194. function followCard(card: FlowCardLayout): void {
  195. const target = card.hop.callRef?.targetId;
  196. if (!target) return;
  197. const next = layout?.cards.find((c) => c.id === target);
  198. if (next) openCard(next);
  199. }
  200. function note(p: WireFlowPayload): string {
  201. if (p.query.kind === 'trail') {
  202. return 'Your trail, read as a flow: each card is opened at the line that carried you to the next one.';
  203. }
  204. if (p.flows.some((f) => f.partial)) {
  205. return 'No static path connects them. The card is where the looking stopped — a call whose target is chosen at runtime — and the cap names the form, the key and who could be on the other side.';
  206. }
  207. if (p.query.kind === 'directed') {
  208. return 'Every card is a call the graph recorded. A dashed link is a hop no one can see in the source — a callback, an interface, a re-render — and it names where it was wired.';
  209. }
  210. return 'The longest call path among the symbols you named, the same one codegraph_explore leads with.';
  211. }
  212. /**
  213. * The strip as it stands, for a PR comment or a README.
  214. *
  215. * Built from `layout` — the same object the canvas is drawing — so the image
  216. * cannot say something the screen does not. The caption names the path,
  217. * because an image pasted into a review has lost the header that did.
  218. */
  219. const exportLabel = $derived(
  220. showAll && flows.length > 1
  221. ? `all ${flows.length} paths`
  222. : (activeFlow?.label ?? 'flow')
  223. );
  224. function buildSvg(scale: number): string {
  225. if (layout === null) throw new Error('There is no strip to export yet.');
  226. const hops = activeFlow?.hops.length ?? 0;
  227. return flowSvg(layout, {
  228. scale,
  229. activeFlowId: picked,
  230. showAll,
  231. caption: showAll ? exportLabel : `${exportLabel}${hops > 1 ? ` · ${hops} hops` : ''}`,
  232. });
  233. }
  234. </script>
  235. <div class="flowview">
  236. <header class="fhead">
  237. <h1>Flow</h1>
  238. {#if flows.length > 0}
  239. <select
  240. aria-label="Which path to draw"
  241. value={showAll ? ALL : (picked ?? '')}
  242. onchange={(event) => {
  243. const value = (event.currentTarget as HTMLSelectElement).value;
  244. showAll = value === ALL;
  245. if (!showAll) picked = value;
  246. }}
  247. >
  248. {#each flows as flow (flow.id)}
  249. <option value={flow.id}
  250. >{flow.label}{flow.hops.length > 1 ? ` · ${flow.hops.length} hops` : ''}</option
  251. >
  252. {/each}
  253. {#if flows.length > 1}
  254. <option value={ALL}>All {flows.length} paths</option>
  255. {/if}
  256. </select>
  257. {/if}
  258. {#if payload}
  259. <p class="note">{note(payload)}</p>
  260. {/if}
  261. {#if layout !== null}
  262. <ExportButtons build={buildSvg} filename={exportFilename('flow', exportLabel)} />
  263. {/if}
  264. </header>
  265. <div class="fstage">
  266. {#if error !== null}
  267. <div class="state">
  268. <h2>The flow could not be built</h2>
  269. <p>{error}</p>
  270. </div>
  271. {:else if loading && payload === null}
  272. <div class="state"><p class="dim">Following the calls…</p></div>
  273. {:else if payload === null}
  274. <div class="state">
  275. <h2>Nothing to follow yet</h2>
  276. <p>
  277. Ask for a path in the search box — “how does execute reach getFile”, or
  278. <span class="mono">execute -&gt; getFile</span> — or walk a trail and read it as a flow.
  279. </p>
  280. </div>
  281. {:else if layout === null}
  282. <div class="state">
  283. <h2>No path between them</h2>
  284. <p>{payload.reason}</p>
  285. {#if payload.query.from && payload.query.to}
  286. <p class="dim">
  287. Asked: <span class="mono">{payload.query.from}</span> to
  288. <span class="mono">{payload.query.to}</span>.
  289. </p>
  290. {/if}
  291. </div>
  292. {:else}
  293. <SvelteFlow
  294. {nodes}
  295. {edges}
  296. {nodeTypes}
  297. {edgeTypes}
  298. initialViewport={START_VIEWPORT}
  299. fitViewOptions={{ padding: 0.1, maxZoom: 1, minZoom: 0.2 }}
  300. minZoom={0.2}
  301. maxZoom={1.4}
  302. nodesDraggable={false}
  303. nodesConnectable={false}
  304. elementsSelectable={false}
  305. panOnDrag
  306. proOptions={{ hideAttribution: true }}
  307. >
  308. <Controls position="bottom-right" showLock={false} />
  309. </SvelteFlow>
  310. {/if}
  311. </div>
  312. {#if payload && (payload.reason !== null || payload.ambiguous.length > 0 || payload.unresolved.length > 0) && layout !== null}
  313. <footer class="fnote">
  314. {#if payload.reason !== null}
  315. <p>{payload.reason}</p>
  316. {/if}
  317. {#each payload.ambiguous as amb (amb.token)}
  318. <p>
  319. <span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.
  320. {#if amb.chosen}
  321. This path runs through the one in
  322. <span class="mono">{basename(amb.chosen.file)}:{amb.chosen.line}</span>.
  323. {:else}
  324. None of them are on this path.
  325. {/if}
  326. </p>
  327. {/each}
  328. {#each payload.unresolved as token (token)}
  329. <p><span class="mono">{token}</span> names nothing in this index.</p>
  330. {/each}
  331. </footer>
  332. {/if}
  333. </div>
  334. <style>
  335. .flowview {
  336. display: grid;
  337. height: 100%;
  338. min-height: 0;
  339. grid-template-rows: auto minmax(0, 1fr) auto;
  340. }
  341. .fhead {
  342. display: flex;
  343. align-items: center;
  344. padding: 12px 18px;
  345. border-bottom: 1px solid var(--rule-soft);
  346. gap: 12px;
  347. }
  348. .fhead h1 {
  349. margin: 0;
  350. font-size: 16px;
  351. font-weight: 600;
  352. }
  353. .fhead select {
  354. padding: 3px 6px;
  355. background: var(--paper-2);
  356. color: var(--ink);
  357. border: 1px solid var(--rule-soft);
  358. border-radius: 0;
  359. font: 12.5px var(--sans);
  360. }
  361. .note {
  362. max-width: 78ch;
  363. margin: 0;
  364. color: var(--ink-3);
  365. font-size: 12px;
  366. }
  367. .fstage {
  368. position: relative;
  369. overflow: hidden;
  370. background: var(--paper);
  371. }
  372. /* Svelte Flow paints its own surface and controls; both are re-tokenised so
  373. the canvas belongs to the paper/ink system. Same treatment as the Map. */
  374. .fstage :global(.svelte-flow) {
  375. background: var(--paper);
  376. }
  377. .fstage :global(.svelte-flow__handle) {
  378. width: 1px;
  379. height: 1px;
  380. min-width: 0;
  381. min-height: 0;
  382. border: 0;
  383. opacity: 0;
  384. pointer-events: none;
  385. }
  386. .fstage :global(.svelte-flow__node) {
  387. cursor: default;
  388. }
  389. .fstage :global(.svelte-flow__controls) {
  390. border: 1px solid var(--rule-soft);
  391. box-shadow: none;
  392. }
  393. .fstage :global(.svelte-flow__controls-button) {
  394. background: var(--paper);
  395. border: 0;
  396. border-bottom: 1px solid var(--rule-soft);
  397. border-radius: 0;
  398. box-shadow: none;
  399. fill: var(--ink-2);
  400. }
  401. .state {
  402. max-width: 52ch;
  403. padding: 40px;
  404. }
  405. .state h2 {
  406. margin: 0 0 8px;
  407. font-size: 15px;
  408. font-weight: 600;
  409. }
  410. .state p {
  411. margin: 0 0 8px;
  412. color: var(--ink-2);
  413. font-size: 12.5px;
  414. line-height: 1.5;
  415. }
  416. .dim {
  417. color: var(--ink-3);
  418. }
  419. .mono {
  420. font-family: var(--mono);
  421. }
  422. .fnote {
  423. padding: 8px 18px;
  424. border-top: 1px solid var(--rule-soft);
  425. background: var(--paper-2);
  426. color: var(--ink-2);
  427. font-size: 12px;
  428. }
  429. .fnote p {
  430. margin: 0 0 2px;
  431. }
  432. </style>