SymbolView.svelte 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. <!--
  2. The Symbol view: callers | verbatim source with gutter ports | line-anchored
  3. callee rail (design spec §3.2, task CG-44).
  4. The geometry is the point of the screen, and it is the one thing that cannot
  5. be derived from the payload: where a callee row belongs depends on where its
  6. call-site line ended up, which depends on the font, the window width, whether
  7. a fold is open. So this component measures — after every render, on every
  8. resize — and hands the rail and the overlay their coordinates. Everything
  9. else it does is plumbing around that.
  10. Two scroll containers, deliberately. The left rail scrolls alone; the centre
  11. and the right rail scroll together inside the stage, because a callee row
  12. that drifts away from its line is worse than no rail at all.
  13. -->
  14. <script lang="ts">
  15. import { tick, untrack } from 'svelte';
  16. import CalleeRail from '../components/symbol/CalleeRail.svelte';
  17. import CallersRail from '../components/symbol/CallersRail.svelte';
  18. import Connectors from '../components/symbol/Connectors.svelte';
  19. import BlastStrip from '../components/symbol/BlastStrip.svelte';
  20. import MembersOutline from '../components/symbol/MembersOutline.svelte';
  21. import TypeHierarchy from '../components/symbol/TypeHierarchy.svelte';
  22. import SourceBlock from '../components/symbol/SourceBlock.svelte';
  23. import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
  24. import DriftBanner from '../components/DriftBanner.svelte';
  25. import {
  26. ApiFailure,
  27. fetchFile,
  28. fetchSource,
  29. fetchSymbol,
  30. type WireNodeDetail,
  31. type WireNodeRef,
  32. type WireSource,
  33. type WireSymbolPayload,
  34. } from '../lib/api';
  35. import { tokensByLine, type Token } from '../lib/highlight';
  36. import { hot, railFocus } from '../lib/focus.svelte';
  37. import { project } from '../lib/project.svelte';
  38. import {
  39. buildCalleeRail,
  40. buildCallerRail,
  41. buildCodeBlock,
  42. buildOutline,
  43. graphCallLines,
  44. refsByLine,
  45. showsBody,
  46. synthesizedBy,
  47. type Connector,
  48. type LineRef,
  49. } from '../lib/symbol-model';
  50. import { encodeTrail, trail } from '../lib/trail.svelte';
  51. import { liveRefresh } from '../lib/live.svelte';
  52. import { fileHref, navigate, symbolHref } from '../lib/navigation';
  53. import { arrivedFrom, walkTo } from '../lib/walk';
  54. interface Props {
  55. id: string;
  56. line: number | null;
  57. }
  58. let { id, line }: Props = $props();
  59. /* ------------------------------------------------------------ geometry -- */
  60. /** Row height and the gap between two rows pushed apart — spec §3.2. */
  61. const ROW_HEIGHT = 34;
  62. const ROW_GAP = 6;
  63. /** Fallback for the sticky rail header before it has been measured. */
  64. const RAIL_HEADER_FALLBACK = 38;
  65. /**
  66. * How much of a DRIFTED file this screen will show in place of the body.
  67. *
  68. * When the file has moved on, the symbol's indexed range names nothing, so
  69. * the only correct source to show is the whole current file — the same call
  70. * `codegraph_node` makes (issue #1474), for the same reason: current bytes
  71. * are right by construction, a slice of them is a guess. Past this length
  72. * that stops being a symbol view and becomes a file view badly done, so the
  73. * banner points at the real one instead.
  74. */
  75. const DRIFT_INLINE_MAX_LINES = 400;
  76. /** One shared empty map, so the drift path does not allocate per render. */
  77. const EMPTY_REFS: Map<number, LineRef[]> = new Map();
  78. /* --------------------------------------------------------------- state -- */
  79. let payload = $state<WireSymbolPayload | null>(null);
  80. let source = $state<WireSource | null>(null);
  81. let failure = $state<ApiFailure | null>(null);
  82. let loading = $state(true);
  83. let innerEl = $state<HTMLDivElement | null>(null);
  84. let centerEl = $state<HTMLElement | null>(null);
  85. let railEl = $state<HTMLElement | null>(null);
  86. let leftRailEl = $state<HTMLElement | null>(null);
  87. let tops = $state<number[]>([]);
  88. let foldTop = $state(0);
  89. let noteTop = $state(0);
  90. let stageMinHeight = $state(0);
  91. let connectors = $state<Connector[]>([]);
  92. let overlay = $state({ width: 0, height: 0 });
  93. /**
  94. * The rail has been measured at least once for the symbol on screen.
  95. *
  96. * Until it has, a row has no place to be: drawing it at `top: 0` would stack
  97. * every row at the head of the rail for a frame, and drawing it at the
  98. * PREVIOUS symbol's coordinates would be worse. It stays hidden instead.
  99. */
  100. let placed = $state(false);
  101. /* ---------------------------------------------------------------- data -- */
  102. $effect(() => {
  103. const wanted = id;
  104. const controller = new AbortController();
  105. untrack(() => load(wanted, controller.signal));
  106. return () => controller.abort();
  107. });
  108. /** Aborts a live-triggered reload when the screen moves on without it. */
  109. let liveController: AbortController | null = null;
  110. // The graph moved, or the file on screen changed on disk. Either way what is
  111. // drawn is out of date; refetch it in place rather than blanking the screen.
  112. liveRefresh(
  113. () => payload?.node.file ?? null,
  114. () => {
  115. const wanted = id;
  116. liveController?.abort();
  117. liveController = new AbortController();
  118. void load(wanted, liveController.signal, true);
  119. }
  120. );
  121. /**
  122. * @param quiet a live refresh rather than a navigation: keep what is on
  123. * screen until the new payload lands, so a sync does not blink the view.
  124. */
  125. async function load(nodeId: string, signal: AbortSignal, quiet = false): Promise<void> {
  126. if (!quiet) {
  127. loading = true;
  128. failure = null;
  129. payload = null;
  130. source = null;
  131. railFocus.reset();
  132. hot.set(null);
  133. placed = false;
  134. }
  135. void project.ensure();
  136. let node: WireSymbolPayload;
  137. try {
  138. node = await fetchSymbol(nodeId, signal);
  139. } catch (cause) {
  140. if (signal.aborted) return;
  141. // A node's id contains its start line, so a sync that moved this symbol
  142. // down two lines answers 404 for an id that was valid a second ago. On a
  143. // live refresh — and only there, because only there do we still hold the
  144. // symbol's name — find it again in its file rather than telling the
  145. // reader their screen no longer exists.
  146. if (quiet && asFailure(cause).code === 'not-found' && payload !== null) {
  147. const moved = await refind(payload.node, signal);
  148. if (signal.aborted) return;
  149. if (moved !== null) {
  150. trail.rename(nodeId, moved);
  151. navigate(symbolHref(moved.id, { trail: encodeTrail(trail.hops) }), { replace: true });
  152. return;
  153. }
  154. }
  155. failure = asFailure(cause);
  156. loading = false;
  157. return;
  158. }
  159. if (signal.aborted) return;
  160. payload = node;
  161. failure = null;
  162. loading = false;
  163. trail.resolve(nodeId, { name: node.node.name, kind: node.node.kind });
  164. // The body is only fetched when it will be drawn: a 2,000-line file node
  165. // shows its outline, and asking for 2,000 lines to throw them away is the
  166. // difference between a screen that settles at once and one that does not.
  167. if (!showsBody(node.node.kind, node.node.lines)) {
  168. source = null;
  169. return;
  170. }
  171. try {
  172. // A drifted file is asked for WHOLE and CURRENT — its indexed range is
  173. // the one thing about it that is certainly wrong — and the answer comes
  174. // back flagged `showing: 'current'`, which is what switches every
  175. // line-anchored marking below off.
  176. const slice = node.drift
  177. ? await fetchSource(node.node.file, 1, 0, signal, 'current')
  178. : await fetchSource(node.node.file, node.node.line, node.node.endLine, signal);
  179. if (!signal.aborted) source = slice;
  180. } catch {
  181. // No slice: the header, the rails and the blast strip are all still
  182. // true, so the screen loses the body and says so rather than erroring.
  183. if (!signal.aborted) source = null;
  184. }
  185. }
  186. /**
  187. * The same symbol after a sync renumbered its file.
  188. *
  189. * The file's outline is the exact answer — every symbol in that file with its
  190. * new id — so this is one request and no ranking. Same name and kind is the
  191. * match; when a file holds several (overloads), the one that moved least is
  192. * the one the reader was on.
  193. */
  194. async function refind(previous: WireNodeDetail, signal: AbortSignal): Promise<WireNodeRef | null> {
  195. try {
  196. const file = await fetchFile(previous.file, signal);
  197. const candidates = file.outline.items.filter(
  198. (entry) => entry.name === previous.name && entry.kind === previous.kind
  199. );
  200. if (candidates.length === 0) return null;
  201. return candidates.reduce((best, entry) =>
  202. Math.abs(entry.line - previous.line) < Math.abs(best.line - previous.line) ? entry : best
  203. );
  204. } catch {
  205. return null;
  206. }
  207. }
  208. function asFailure(cause: unknown): ApiFailure {
  209. if (cause instanceof ApiFailure) return cause;
  210. return new ApiFailure(0, 'error', cause instanceof Error ? cause.message : String(cause), null);
  211. }
  212. /* -------------------------------------------------------------- models -- */
  213. let callers = $derived(payload ? buildCallerRail(payload) : null);
  214. let callees = $derived(payload ? buildCalleeRail(payload) : null);
  215. let refs = $derived(payload ? refsByLine(payload) : new Map<number, LineRef[]>());
  216. let outline = $derived(payload ? buildOutline(payload) : []);
  217. let wantsBody = $derived(payload ? showsBody(payload.node.kind, payload.node.lines) : false);
  218. /**
  219. * The body on screen is the file's CURRENT source, not this symbol's.
  220. *
  221. * Everything the graph knows is anchored to line numbers the file no longer
  222. * has, so in this mode the ports, the call-site links, the definition-name
  223. * weight and the `?line=` highlight are all switched off together. Leaving
  224. * any one of them on would put a marking from the previous version of the
  225. * file over a line of the new one — a lie that looks exactly like the truth.
  226. */
  227. let showingCurrent = $derived(source?.showing === 'current');
  228. /** A drifted file too long to stand in for the body; the banner links out. */
  229. let driftTooLong = $derived(
  230. payload?.drift === true &&
  231. (source === null || (source.totalLines ?? 0) > DRIFT_INLINE_MAX_LINES)
  232. );
  233. let codeBlock = $derived.by(() => {
  234. if (!payload || !source?.lines) return null;
  235. if (showingCurrent) {
  236. if (driftTooLong) return null;
  237. return buildCodeBlock(source.from ?? 1, source.lines, []);
  238. }
  239. const from = source.from ?? payload.node.line;
  240. return buildCodeBlock(from, source.lines, graphCallLines(payload));
  241. });
  242. /**
  243. * Classified source by file line, from `/api/source`.
  244. *
  245. * Keyed by real file line rather than by window offset, because a windowed
  246. * body renumbers nothing: the gaps are holes in the same numbering, and the
  247. * code block looks a line up by the number it prints in the gutter.
  248. */
  249. let codeTokens = $derived.by(() => {
  250. if (!source?.lines) return new Map<number, Token[]>();
  251. return tokensByLine(source.lines, source.from ?? 1, source.highlight);
  252. });
  253. let origin = $derived(arrivedFrom());
  254. let originLeft = $derived(origin?.rail === 'left' ? origin.id : null);
  255. let originRight = $derived(origin?.rail === 'right' ? origin.id : null);
  256. let emptyCalleeReason = $derived.by(() => {
  257. if (!payload) return '';
  258. if (!wantsBody) {
  259. return `A ${payload.node.kind.replace(/_/g, ' ')} makes no calls itself — its members do. Open one from the outline.`;
  260. }
  261. return 'This symbol makes no resolved calls — a leaf.';
  262. });
  263. /* ------------------------------------------------------------ movement -- */
  264. /**
  265. * Follow a call. No line is carried across: the call-site line belongs to the
  266. * symbol being left, and the destination opens at its own definition.
  267. */
  268. function stepDown(node: WireNodeRef): void {
  269. walkTo(node, 'down');
  270. }
  271. /** Go to a caller, landing on the line that makes the call when one is named. */
  272. function stepUp(node: WireNodeRef, at?: number): void {
  273. walkTo(node, 'up', at);
  274. }
  275. /** A jump that is neither up nor down: a breadcrumb, a chip, a member. */
  276. function open(node: WireNodeRef): void {
  277. walkTo(node, 'start');
  278. }
  279. function followRef(ref: LineRef): void {
  280. if (!ref.targetId) return;
  281. const target = payload?.outgoing.items.find((r) => r.node.id === ref.targetId)?.node
  282. ?? payload?.typesUsed.find((r) => r.node.id === ref.targetId)?.node;
  283. if (target) walkTo(target, 'down');
  284. }
  285. /* ------------------------------------------------------------ keyboard -- */
  286. function leftRows(): WireNodeRef[] {
  287. return (callers?.groups ?? []).flatMap((group) => group.rows.map((row) => row.relation.node));
  288. }
  289. function rightRows(): WireNodeRef[] {
  290. return (callees?.rows ?? []).map((row) => row.relation.node);
  291. }
  292. function activeRows(): WireNodeRef[] {
  293. return railFocus.rail === 'left' ? leftRows() : rightRows();
  294. }
  295. function onkeydown(event: KeyboardEvent): void {
  296. if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
  297. const target = event.target;
  298. if (
  299. target instanceof HTMLElement &&
  300. (target.isContentEditable ||
  301. target instanceof HTMLInputElement ||
  302. target instanceof HTMLTextAreaElement ||
  303. target instanceof HTMLSelectElement)
  304. ) {
  305. return;
  306. }
  307. if (!payload) return;
  308. switch (event.key) {
  309. case 'ArrowLeft':
  310. railFocus.switchTo('left');
  311. break;
  312. case 'ArrowRight':
  313. railFocus.switchTo('right');
  314. break;
  315. case 'ArrowDown':
  316. case 'j':
  317. railFocus.step(1, activeRows().length);
  318. break;
  319. case 'ArrowUp':
  320. case 'k':
  321. railFocus.step(-1, activeRows().length);
  322. break;
  323. case 'Enter': {
  324. const node = activeRows()[railFocus.index];
  325. if (node) {
  326. event.preventDefault();
  327. if (railFocus.rail === 'left') stepUp(node);
  328. else stepDown(node);
  329. }
  330. return;
  331. }
  332. default:
  333. return;
  334. }
  335. event.preventDefault();
  336. // Keep the selection on screen; the rails are the only thing that scrolls
  337. // out from under the keyboard.
  338. void tick().then(() => {
  339. const scope = railFocus.rail === 'left' ? leftRailEl : railEl;
  340. // Rows are the only focusable buttons in a rail, and they render in the
  341. // same order the keyboard walks them.
  342. scope?.querySelectorAll('[role="button"]')[railFocus.index]?.scrollIntoView({
  343. block: 'nearest',
  344. });
  345. });
  346. }
  347. /* ----------------------------------------------------------- measuring -- */
  348. /**
  349. * Place every callee row beside its call site, then draw the connectors.
  350. *
  351. * Rows are laid out in source order and never allowed to overlap: a row wants
  352. * to sit at the centre of its first call-site line, but takes
  353. * `previous + height + gap` when that would collide. Order beats exactness —
  354. * a rail whose rows jump around relative to the body stops being a reading of
  355. * the code — and the connector still runs to the line, so the displacement is
  356. * visible rather than silent.
  357. */
  358. function relayout(): void {
  359. const inner = innerEl;
  360. const center = centerEl;
  361. const rail = railEl;
  362. const rows = callees?.rows ?? [];
  363. if (!inner || !center || !rail) return;
  364. const headerHeight =
  365. rail.querySelector<HTMLElement>('[data-rail-header]')?.offsetHeight ?? RAIL_HEADER_FALLBACK;
  366. // A drifted file's body is the CURRENT source under CURRENT numbering, and
  367. // the rail's anchors are the numbers the index recorded. A line that
  368. // happens to exist in both is a coincidence, not a call site — so in that
  369. // mode nothing is anchored: the rows stack in source order and no
  370. // connector is drawn. The rail is still true about WHAT this symbol calls;
  371. // it has stopped being true about WHERE, and says so by not pointing.
  372. const anchored = !showingCurrent;
  373. const lineCentre = (n: number): number | null => {
  374. if (!anchored) return null;
  375. const el = center.querySelector<HTMLElement>(`[data-line="${n}"]`);
  376. return el ? el.offsetTop + el.offsetHeight / 2 : null;
  377. };
  378. let y = headerHeight + 14;
  379. const nextTops: number[] = [];
  380. const rowCentres: Array<number | null> = [];
  381. for (const row of rows) {
  382. const centre = row.anchor !== null ? lineCentre(row.anchor) : null;
  383. const wanted = centre !== null ? centre - ROW_HEIGHT / 2 : y;
  384. y = Math.max(wanted, y);
  385. nextTops.push(y);
  386. rowCentres.push(y + ROW_HEIGHT / 2);
  387. y += ROW_HEIGHT + ROW_GAP;
  388. }
  389. const nextFoldTop = y + 8;
  390. if ((callees?.uncertain.length ?? 0) > 0) {
  391. const fold = rail.querySelector<HTMLElement>('[data-rail-fold]');
  392. y = nextFoldTop + (fold?.offsetHeight ?? 30);
  393. }
  394. const nextNoteTop = y + 14;
  395. tops = nextTops;
  396. foldTop = nextFoldTop;
  397. noteTop = nextNoteTop;
  398. stageMinHeight = Math.max(center.offsetHeight, nextNoteTop + 60);
  399. // Connectors: one per call site, from the centre column's right edge to the
  400. // row's own centre. Both coordinate systems are the stage's, so the port
  401. // and the row agree even when the stage is scrolled.
  402. const x0 = center.offsetLeft + center.offsetWidth - 10;
  403. const x1 = rail.offsetLeft + 14;
  404. const cx = (x0 + x1) / 2;
  405. const next: Connector[] = [];
  406. rows.forEach((row, index) => {
  407. const ry = rowCentres[index];
  408. if (ry == null) return;
  409. const via = synthesizedBy(row.relation);
  410. for (const callLine of row.lines) {
  411. const ly = lineCentre(callLine);
  412. if (ly === null) continue;
  413. next.push({
  414. d: `M${x0},${ly} C${cx},${ly} ${cx},${ry} ${x1},${ry}`,
  415. targetId: row.relation.node.id,
  416. uncertain: row.relation.uncertain,
  417. heuristic: via !== null,
  418. origin: row.relation.node.id === originRight,
  419. });
  420. }
  421. });
  422. connectors = next;
  423. overlay = { width: inner.scrollWidth, height: Math.max(inner.offsetHeight, stageMinHeight) };
  424. placed = true;
  425. }
  426. let scheduled = false;
  427. function scheduleRelayout(): void {
  428. if (scheduled) return;
  429. scheduled = true;
  430. requestAnimationFrame(() => {
  431. scheduled = false;
  432. relayout();
  433. });
  434. }
  435. // Re-measure whenever what is drawn changes. The dependencies are the INPUTS
  436. // (the models and the block); the outputs it writes are read untracked inside
  437. // relayout(), so this cannot feed itself.
  438. $effect(() => {
  439. void codeBlock;
  440. void callees;
  441. void outline;
  442. void payload;
  443. void tick().then(scheduleRelayout);
  444. });
  445. // Layout is a function of pixels, not of data: a resized window, a loaded
  446. // font and an opened fold all move the lines without changing the payload.
  447. $effect(() => {
  448. const inner = innerEl;
  449. const center = centerEl;
  450. const rail = railEl;
  451. if (!inner || !center || !rail) return;
  452. const observer = new ResizeObserver(scheduleRelayout);
  453. observer.observe(inner);
  454. observer.observe(center);
  455. observer.observe(rail);
  456. // Opening a fold moves the rail's contents without resizing any box the
  457. // observer watches — the folds are absolutely positioned. `toggle` does not
  458. // bubble, so it is caught on the way down.
  459. inner.addEventListener('toggle', scheduleRelayout, true);
  460. void document.fonts?.ready.then(scheduleRelayout);
  461. return () => {
  462. observer.disconnect();
  463. inner.removeEventListener('toggle', scheduleRelayout, true);
  464. };
  465. });
  466. // Scroll the highlighted call site into view once, when it first appears —
  467. // and not again, so a later resize does not yank the reader back to it.
  468. let scrolledTo: string | null = null;
  469. $effect(() => {
  470. const key = line === null ? null : `${id}:${line}`;
  471. const center = centerEl;
  472. if (!key || !center || !codeBlock || scrolledTo === key) return;
  473. const el = center.querySelector(`[data-line="${line}"]`);
  474. if (!el) return;
  475. scrolledTo = key;
  476. el.scrollIntoView({ block: 'center' });
  477. });
  478. </script>
  479. <svelte:window {onkeydown} />
  480. {#if failure}
  481. <div class="scroll">
  482. <div class="emptystate">
  483. <h2>{failure.code === 'not-found' ? 'No such symbol' : 'Could not load this symbol'}</h2>
  484. <p>{failure.message}</p>
  485. {#if failure.guidance}<p class="dim">{failure.guidance}</p>{/if}
  486. </div>
  487. </div>
  488. {:else if loading || !payload || !callers || !callees}
  489. <div class="scroll">
  490. <div class="emptystate"><p class="dim">Loading…</p></div>
  491. </div>
  492. {:else}
  493. <div class="focus">
  494. <aside class="rail-left" bind:this={leftRailEl} aria-label="Called by">
  495. <CallersRail
  496. model={callers}
  497. originId={originLeft}
  498. exported={payload.node.exported === true}
  499. onstepUp={stepUp}
  500. />
  501. </aside>
  502. <div class="stage">
  503. <div class="stage-inner" bind:this={innerEl} style:min-height={`${stageMinHeight}px`}>
  504. <Connectors {connectors} width={overlay.width} height={overlay.height} />
  505. <section class="center" bind:this={centerEl}>
  506. <SymbolHeader {payload} onopen={open} relationChips={!payload.hierarchy} />
  507. {#if payload.drift}
  508. <div class="banner">
  509. <DriftBanner file={payload.node.file}>
  510. {#if driftTooLong}
  511. indexed line ranges may be shifted, and the file is too long to stand in for
  512. this symbol's body here —
  513. <a href={fileHref(payload.node.file, { source: true })}>open its current source</a>.
  514. The next sync picks it up.
  515. {:else}
  516. indexed line ranges may be shifted; showing the file's current source. The next
  517. sync picks it up.
  518. {/if}
  519. </DriftBanner>
  520. </div>
  521. {/if}
  522. {#if payload.hierarchy}
  523. <TypeHierarchy hierarchy={payload.hierarchy} focus={payload.node} onopen={open} />
  524. {/if}
  525. {#if codeBlock}
  526. <SourceBlock
  527. block={codeBlock}
  528. tokens={codeTokens}
  529. refs={showingCurrent ? EMPTY_REFS : refs}
  530. defLine={showingCurrent ? -1 : payload.node.line}
  531. defName={showingCurrent ? '' : payload.node.name}
  532. highlight={showingCurrent ? null : line}
  533. onfollow={followRef}
  534. />
  535. {:else if payload.drift}
  536. <!-- The banner above is the whole answer for this file. -->
  537. {:else if !wantsBody}
  538. <!-- The outline below IS the body for a container this size. -->
  539. {:else if source}
  540. <div class="note">{source.reason ?? 'Source is not available for this symbol.'}</div>
  541. {/if}
  542. {#if outline.length > 0}
  543. <MembersOutline
  544. rows={outline}
  545. total={payload.members.total}
  546. truncated={payload.members.truncated}
  547. onopen={open}
  548. />
  549. {/if}
  550. {#if payload.blast}
  551. <BlastStrip
  552. blast={payload.blast}
  553. scale={project.stats?.blastScale ?? null}
  554. testCalls={callers.tests.calls}
  555. testFiles={callers.tests.files.length}
  556. />
  557. {/if}
  558. </section>
  559. <aside class="rail-right" bind:this={railEl} aria-label="Calls">
  560. <CalleeRail
  561. model={callees}
  562. {tops}
  563. {foldTop}
  564. {noteTop}
  565. {placed}
  566. focalFile={payload.node.file}
  567. originId={originRight}
  568. emptyReason={emptyCalleeReason}
  569. onstepDown={stepDown}
  570. />
  571. </aside>
  572. </div>
  573. </div>
  574. </div>
  575. {/if}
  576. <style>
  577. .scroll {
  578. height: 100%;
  579. overflow: auto;
  580. }
  581. .focus {
  582. display: grid;
  583. grid-template-columns: 300px minmax(520px, 1fr);
  584. height: 100%;
  585. min-height: 0;
  586. }
  587. .rail-left {
  588. overflow: auto;
  589. border-right: 1px solid var(--rule-soft);
  590. background: var(--paper);
  591. }
  592. .stage {
  593. position: relative;
  594. overflow: auto;
  595. }
  596. /* The positioning context every measured coordinate is expressed in: line
  597. offsets, rail row tops and the SVG overlay all share this origin. */
  598. .stage-inner {
  599. position: relative;
  600. display: grid;
  601. grid-template-columns: minmax(480px, 1fr) 320px;
  602. min-height: 100%;
  603. }
  604. .center {
  605. min-width: 0;
  606. padding: 18px 22px 40px;
  607. }
  608. .rail-right {
  609. position: relative;
  610. border-left: 1px solid var(--rule-faint);
  611. }
  612. .banner {
  613. margin: 16px 0 4px;
  614. }
  615. .note {
  616. padding: 12px 0;
  617. color: var(--ink-3);
  618. font-size: 12px;
  619. }
  620. @media (max-width: 1100px) {
  621. .focus {
  622. grid-template-columns: 240px minmax(360px, 1fr);
  623. }
  624. .stage-inner {
  625. grid-template-columns: minmax(360px, 1fr) 260px;
  626. }
  627. }
  628. </style>