@shortlink-org/portolan 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
@@ -10,8 +10,16 @@ import { SourcePreviewButton } from "../components/SourcePreview";
10
10
  import { flowRepoService } from "../lib/derive";
11
11
  import { sourceLocation } from "../lib/source-link";
12
12
  import { AdrNumber, StatusChip } from "../components/primitives";
13
+ import { ShapeRows } from "../components/ShapeRows";
14
+ import { shapeFor } from "../components/MethodRows";
13
15
  import { stepAnswer } from "./answers";
14
- import { paths, eventPath, servicePath } from "../routes";
16
+ import {
17
+ aggregatePath,
18
+ paths,
19
+ eventPath,
20
+ servicePath,
21
+ storePath,
22
+ } from "../routes";
15
23
 
16
24
  function Label({ children }: { children: React.ReactNode }) {
17
25
  return <div className="label mt-3 mb-1">{children}</div>;
@@ -156,42 +164,237 @@ function RpcDetail({ step, flow }: { step: Step; flow: Flow }) {
156
164
  const answer = stepAnswer(index, step);
157
165
 
158
166
  return (
159
- <>
160
- <Ident block value={method} className="text-ink" />
167
+ <section
168
+ aria-label="RPC contract"
169
+ className="overflow-hidden rounded-card border shadow-xs border-line"
170
+ >
171
+ <header className="border-b px-3 py-2.5 border-line bg-surface">
172
+ <h2 className="label">RPC contract</h2>
173
+ <div className="mt-2">
174
+ <Ident block value={method} className="text-ink" />
175
+ </div>
176
+ </header>
161
177
 
162
- {answer ? (
163
- <>
164
- <Label>Answers with</Label>
165
- <div className="mono text-muted">{answer}</div>
166
- </>
178
+ <DetailSection title="Contract">
179
+ <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2">
180
+ {answer ? (
181
+ <>
182
+ <dt className="mono text-muted">Answers with</dt>
183
+ <dd className="mono break-all text-ink">{answer}</dd>
184
+ </>
185
+ ) : null}
186
+ <dt className="mono text-muted">Provider</dt>
187
+ <dd>
188
+ {provider && providerPath ? (
189
+ <Link to={providerPath} className="mono text-accent">
190
+ {provider.id} →
191
+ </Link>
192
+ ) : (
193
+ <span className="mono inline-flex items-center gap-1.5 rounded-control border px-1.5 py-0.5 status-unresolved">
194
+ <AlertTriangle size={11} aria-hidden />
195
+ no provider found
196
+ </span>
197
+ )}
198
+ </dd>
199
+ {call ? (
200
+ <>
201
+ <dt className="mono text-muted">Declared by</dt>
202
+ <dd className="mono break-all text-ink">{call.peer}</dd>
203
+ </>
204
+ ) : null}
205
+ </dl>
206
+ </DetailSection>
207
+
208
+ <DetailSection title="Source">
209
+ {call ? (
210
+ <SourceWhere where={call.source} flow={flow} structured />
211
+ ) : step.line ? (
212
+ <SourceWhere where={step.line} flow={flow} structured />
213
+ ) : (
214
+ <div className="mono text-muted">not recorded</div>
215
+ )}
216
+ </DetailSection>
217
+
218
+ {step.line ? (
219
+ <DetailSection title="Observed at">
220
+ <SourceWhere where={step.line} flow={flow} structured />
221
+ </DetailSection>
167
222
  ) : null}
223
+ </section>
224
+ );
225
+ }
168
226
 
169
- <Label>Provider</Label>
170
- {provider && providerPath ? (
171
- <Link to={providerPath} className="mono text-accent">
172
- {provider.id} →
173
- </Link>
174
- ) : (
175
- <div className="mono inline-flex items-center gap-1.5 border px-1.5 py-0.5 status-unresolved">
176
- <AlertTriangle size={11} aria-hidden />
177
- no provider found
227
+ function DetailSection({
228
+ title,
229
+ meta,
230
+ children,
231
+ }: {
232
+ title: string;
233
+ meta?: React.ReactNode;
234
+ children: React.ReactNode;
235
+ }) {
236
+ return (
237
+ <section className="border-b border-line px-3 py-3 last:border-b-0">
238
+ <div className="mb-2 flex items-baseline justify-between gap-3">
239
+ <h3 className="label">{title}</h3>
240
+ {meta ? <span className="mono text-muted">{meta}</span> : null}
241
+ </div>
242
+ {children}
243
+ </section>
244
+ );
245
+ }
246
+
247
+ function ResponseDetail({ step, flow }: { step: Step; flow: Flow }) {
248
+ const response = step.http;
249
+ if (!response) {
250
+ return (
251
+ <>
252
+ <div className="mono text-[13px]">{step.label ?? "response"}</div>
253
+ <Label>Source</Label>
254
+ <Where step={step} flow={flow} />
255
+ </>
256
+ );
257
+ }
258
+
259
+ const cut = response.bodyRef?.lastIndexOf("/") ?? -1;
260
+ const interfaceId = cut >= 0 ? response.bodyRef!.slice(0, cut) : "";
261
+ const methodName = cut >= 0 ? response.bodyRef!.slice(cut + 1) : "";
262
+ const provider = response.bodyRef
263
+ ? index.rpcProviderByMethod.get(response.bodyRef)
264
+ : undefined;
265
+ const provided = provider?.provides.find((item) => item.id === interfaceId);
266
+ const method = provided?.methods.find((item) => item.name === methodName);
267
+ const fields = provided
268
+ ? shapeFor(provided, method?.response ?? response.body, method?.responseRef)
269
+ : (response.fields ?? null);
270
+ const providerPath = provider ? servicePath(provider.id) : null;
271
+ const error = response.outcome === "error";
272
+ const headerCount = response.contentType ? 1 : 0;
273
+
274
+ return (
275
+ <section
276
+ aria-label="HTTP response contract"
277
+ className="overflow-hidden rounded-card border shadow-xs"
278
+ style={{
279
+ borderColor: error ? "var(--response-error)" : "var(--border)",
280
+ }}
281
+ >
282
+ <header
283
+ className="border-b border-line px-3 py-2.5"
284
+ style={{
285
+ background: error ? "var(--response-error-bg)" : "var(--surface)",
286
+ }}
287
+ >
288
+ <div className="flex items-center justify-between gap-3">
289
+ <h2 className="label">Response contract</h2>
290
+ <span
291
+ className="mono rounded-[4px] border px-1.5 py-px"
292
+ style={{
293
+ borderColor: error
294
+ ? "var(--response-error)"
295
+ : "var(--status-verified)",
296
+ color: error ? "var(--response-error)" : "var(--status-verified)",
297
+ }}
298
+ >
299
+ {error ? "error path" : "success"}
300
+ </span>
178
301
  </div>
179
- )}
302
+ <div className="mt-2 flex flex-wrap items-baseline gap-2">
303
+ <span className="mono text-sm font-medium text-ink">
304
+ HTTP {response.status ?? "status unknown"}
305
+ </span>
306
+ <span className="mono text-muted">{response.body ?? "response"}</span>
307
+ </div>
308
+ </header>
180
309
 
181
- {call ? (
182
- <>
183
- <Label>Declared by</Label>
184
- <div className="mono text-muted">peer: {call.peer}</div>
185
- <Label>Source</Label>
186
- <SourceWhere where={call.source} flow={flow} />
187
- </>
188
- ) : (
189
- <>
190
- <Label>Source</Label>
191
- <Where step={step} flow={flow} />
192
- </>
193
- )}
194
- </>
310
+ <DetailSection title="Headers" meta={headerCount}>
311
+ {headerCount > 0 ? (
312
+ <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
313
+ <dt className="mono text-muted">Content-Type</dt>
314
+ <dd className="mono break-all text-ink">{response.contentType}</dd>
315
+ </dl>
316
+ ) : (
317
+ <div className="mono text-muted">no explicit headers detected</div>
318
+ )}
319
+ </DetailSection>
320
+
321
+ <DetailSection
322
+ title="Body"
323
+ meta={
324
+ fields
325
+ ? `${fields.length} field${fields.length === 1 ? "" : "s"}`
326
+ : undefined
327
+ }
328
+ >
329
+ <div className="flex flex-col gap-3">
330
+ <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
331
+ <dt className="mono text-muted">Schema</dt>
332
+ <dd className="mono break-all text-ink">
333
+ {response.body ?? "unknown"}
334
+ </dd>
335
+ {response.encoding ? (
336
+ <>
337
+ <dt className="mono text-muted">Encoding</dt>
338
+ <dd className="mono text-ink">{response.encoding}</dd>
339
+ </>
340
+ ) : null}
341
+ </dl>
342
+
343
+ {fields ? (
344
+ <div className="overflow-x-auto rounded-control border border-line bg-canvas px-2.5 py-2">
345
+ {fields.length > 0 ? (
346
+ <ShapeRows fields={fields} enums={provided?.enums} showHeader />
347
+ ) : (
348
+ <div className="mono text-muted">empty message</div>
349
+ )}
350
+ </div>
351
+ ) : null}
352
+ </div>
353
+ </DetailSection>
354
+
355
+ {response.bodyRef ? (
356
+ <DetailSection title="Contract lineage">
357
+ <dl className="grid grid-cols-[minmax(7rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
358
+ <dt className="mono text-muted">RPC response</dt>
359
+ <dd>
360
+ <Ident block value={response.bodyRef} className="text-ink" />
361
+ </dd>
362
+ {provider && providerPath ? (
363
+ <>
364
+ <dt className="mono text-muted">Provider</dt>
365
+ <dd>
366
+ <Link to={providerPath} className="mono text-accent">
367
+ {provider.id} →
368
+ </Link>
369
+ </dd>
370
+ </>
371
+ ) : null}
372
+ </dl>
373
+ </DetailSection>
374
+ ) : null}
375
+
376
+ {response.warning ? (
377
+ <DetailSection title="Diagnostics" meta="1 issue">
378
+ <div
379
+ className="flex items-start gap-2 rounded-control border px-2.5 py-2"
380
+ style={{
381
+ borderColor: "var(--response-error)",
382
+ color: "var(--response-error)",
383
+ background: "var(--response-error-bg)",
384
+ }}
385
+ >
386
+ <AlertTriangle size={13} aria-hidden className="mt-0.5 shrink-0" />
387
+ <span>{response.warning}</span>
388
+ </div>
389
+ </DetailSection>
390
+ ) : null}
391
+
392
+ {step.line ? (
393
+ <DetailSection title="Observed at">
394
+ <SourceWhere where={step.line} flow={flow} structured />
395
+ </DetailSection>
396
+ ) : null}
397
+ </section>
195
398
  );
196
399
  }
197
400
 
@@ -202,68 +405,99 @@ const FRAME_KEYWORD: Record<StepFrame["kind"], string> = {
202
405
  };
203
406
 
204
407
  /**
205
- * The frames this step sits inside, outermost first. Selecting a step from a
206
- * graph or the palette gives no sense of where in the flow it is, and a step
207
- * pulled out of an alt is the one most likely to be misread: it does not run
208
- * on every path, and nothing else on this panel would say so.
408
+ * Where a step travels and the frames that allow it to run. Selecting a step
409
+ * from a graph gives no sense of its branch, so route and condition are one
410
+ * execution context rather than two loose lines above the contract.
209
411
  */
210
- function Frames({ step, flow }: { step: Step; flow: Flow }) {
412
+ function ExecutionContext({ step, flow }: { step: Step; flow: Flow }) {
211
413
  const frames = useMemo(
212
414
  () => stepFrames(flow.steps).get(step.id) ?? [],
213
415
  [flow, step.id],
214
416
  );
215
- if (frames.length === 0) return null;
216
-
217
417
  const conditional = frames.some((f) => f.kind === "alt");
218
418
 
219
419
  return (
220
- <div className="mb-3">
221
- <div className="label mb-1">
222
- {conditional ? "Runs only when" : "Runs inside"}
420
+ <section className="mb-3 overflow-hidden rounded-card border shadow-xs border-line">
421
+ <header className="flex items-center justify-between gap-3 border-b px-3 py-2 border-line bg-surface">
422
+ <h2 className="label">Execution context</h2>
423
+ {conditional ? (
424
+ <span className="mono rounded-[4px] border px-1.5 py-px border-line-strong text-muted">
425
+ conditional
426
+ </span>
427
+ ) : null}
428
+ </header>
429
+
430
+ <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-3 py-2.5">
431
+ <div className="min-w-0">
432
+ <div className="label mb-1">From</div>
433
+ <div className="mono break-all text-ink">{step.from}</div>
434
+ </div>
435
+ <span className="mono text-muted" aria-hidden>
436
+
437
+ </span>
438
+ <div className="min-w-0">
439
+ <div className="label mb-1">To</div>
440
+ <div className="mono break-all text-ink">{step.to}</div>
441
+ </div>
223
442
  </div>
224
- <div className="flex flex-col gap-1">
225
- {frames.map((frame, i) => {
226
- const alt = frame.kind === "alt";
227
- const text =
228
- frame.kind === "parallel"
229
- ? [frame.title, `branch ${frame.branch}`]
230
- .filter(Boolean)
231
- .join(" · ")
232
- : (frame.branch ?? frame.title ?? "");
233
- return (
234
- <div
235
- key={`${frame.id}:${i}`}
236
- className="flex items-baseline gap-1.5 border-l-2 py-0.5 pl-2"
237
- style={{
238
- borderColor: alt ? "var(--border-strong)" : "var(--border)",
239
- }}
240
- >
241
- <span
242
- className="mono shrink-0 border px-1 uppercase"
243
- style={{
244
- borderColor: alt ? "var(--border-strong)" : "var(--border)",
245
- color: alt ? "var(--fg)" : "var(--fg-muted)",
246
- }}
247
- >
248
- {FRAME_KEYWORD[frame.kind]}
249
- </span>
250
- <span className="mono min-w-0 flex-1" title={text}>
251
- {text}
252
- </span>
253
- {frame.terminal ? (
254
- <span
255
- className="mono shrink-0"
256
- style={{ color: "var(--status-unresolved)" }}
257
- title="This branch ends the flow — the steps drawn after it do not follow this one"
443
+
444
+ {frames.length > 0 ? (
445
+ <div className="border-t px-3 py-2.5 border-line">
446
+ <div className="mb-2 flex items-baseline justify-between gap-3">
447
+ <h3 className="label">
448
+ {conditional ? "Runs only when" : "Runs inside"}
449
+ </h3>
450
+ <span className="mono text-muted">
451
+ {frames.length} frame{frames.length === 1 ? "" : "s"}
452
+ </span>
453
+ </div>
454
+ <div className="flex flex-col gap-1.5">
455
+ {frames.map((frame, i) => {
456
+ const alt = frame.kind === "alt";
457
+ const text =
458
+ frame.kind === "parallel"
459
+ ? [frame.title, `branch ${frame.branch}`]
460
+ .filter(Boolean)
461
+ .join(" · ")
462
+ : (frame.branch ?? frame.title ?? "");
463
+ return (
464
+ <div
465
+ key={`${frame.id}:${i}`}
466
+ className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-control border px-2 py-1.5 border-line bg-canvas"
258
467
  >
259
- ends flow
260
- </span>
261
- ) : null}
262
- </div>
263
- );
264
- })}
265
- </div>
266
- </div>
468
+ <span
469
+ className="mono shrink-0 rounded-[4px] border px-1.5 py-px uppercase"
470
+ style={{
471
+ borderColor: alt
472
+ ? "var(--border-strong)"
473
+ : "var(--border)",
474
+ color: alt ? "var(--fg)" : "var(--fg-muted)",
475
+ }}
476
+ >
477
+ {FRAME_KEYWORD[frame.kind]}
478
+ </span>
479
+ <span
480
+ className="mono min-w-0 break-words text-ink"
481
+ title={text}
482
+ >
483
+ {text}
484
+ </span>
485
+ {frame.terminal ? (
486
+ <span
487
+ className="mono shrink-0"
488
+ style={{ color: "var(--status-unresolved)" }}
489
+ title="This branch ends the flow — the steps drawn after it do not follow this one"
490
+ >
491
+ ends flow
492
+ </span>
493
+ ) : null}
494
+ </div>
495
+ );
496
+ })}
497
+ </div>
498
+ </div>
499
+ ) : null}
500
+ </section>
267
501
  );
268
502
  }
269
503
 
@@ -276,24 +510,61 @@ function Frames({ step, flow }: { step: Step; flow: Flow }) {
276
510
  * `file:line` in the repository this was built from opens on the line; a
277
511
  * trace id or a path in another repository stays as it is, to copy.
278
512
  */
279
- function SourceWhere({ where, flow }: { where: string; flow: Flow }) {
280
- const location = sourceLocation(where, flowRepoService(catalog, flow), allRepos(catalog));
281
- return (
282
- <div className="mono flex flex-wrap items-center gap-2 break-all text-muted">
283
- <Ident block value={where} className="text-muted" />
284
- <SourcePreviewButton location={location} />
513
+ function SourceWhere({
514
+ where,
515
+ flow,
516
+ structured = false,
517
+ }: {
518
+ where: string;
519
+ flow: Flow;
520
+ structured?: boolean;
521
+ }) {
522
+ const location = sourceLocation(
523
+ where,
524
+ flowRepoService(catalog, flow),
525
+ allRepos(catalog),
526
+ );
527
+
528
+ const actions = (
529
+ <>
530
+ <SourcePreviewButton
531
+ location={location}
532
+ className="border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
533
+ />
285
534
  {location?.href ? (
286
535
  <a
287
536
  href={location.href}
288
537
  target="_blank"
289
538
  rel="noreferrer"
290
- className="rounded-control text-accent hover:underline"
539
+ className="mono inline-flex items-center rounded-control border px-2 py-1 border-line bg-canvas text-accent hover:bg-raised"
291
540
  title="Open on the forge, at the built commit"
292
541
  >
293
- open
542
+ forge
294
543
  </a>
295
544
  ) : null}
296
- <EditorLink location={location} variant="text" />
545
+ <EditorLink
546
+ location={location}
547
+ variant="text"
548
+ className="inline-flex items-center border px-2 py-1 border-line bg-canvas hover:bg-raised hover:no-underline"
549
+ />
550
+ </>
551
+ );
552
+
553
+ if (structured) {
554
+ return (
555
+ <div className="flex min-w-0 flex-col gap-2">
556
+ <div className="mono min-w-0 break-all text-muted">
557
+ <Ident block value={where} className="text-muted" />
558
+ </div>
559
+ <div className="flex flex-wrap items-center gap-1.5">{actions}</div>
560
+ </div>
561
+ );
562
+ }
563
+
564
+ return (
565
+ <div className="mono flex flex-wrap items-center gap-2 break-all text-muted">
566
+ <Ident block value={where} className="text-muted" />
567
+ {actions}
297
568
  </div>
298
569
  );
299
570
  }
@@ -303,18 +574,100 @@ function Where({ step, flow }: { step: Step; flow: Flow }) {
303
574
  return <SourceWhere where={step.line} flow={flow} />;
304
575
  }
305
576
 
577
+ function StoreCallDetail({ step, flow }: { step: Step; flow: Flow }) {
578
+ const access = step.storeAccess!;
579
+ const store = index.storeById.get(access.store);
580
+ const keyspace = store?.keyspaces?.find(
581
+ (candidate) => candidate.pattern === access.keyspace,
582
+ );
583
+ const aggregateId = keyspace?.persists?.aggregate;
584
+ const toStore = storePath(access.store);
585
+ const toAggregate = aggregateId ? aggregatePath(aggregateId) : null;
586
+
587
+ return (
588
+ <section
589
+ aria-label="Store access"
590
+ className="overflow-hidden rounded-card border shadow-xs border-line"
591
+ >
592
+ <header className="border-b border-line bg-surface px-3 py-2.5">
593
+ <h2 className="label">Store access</h2>
594
+ <div className="mt-2 flex flex-wrap items-center gap-2">
595
+ {access.operation ? (
596
+ <span className="chip uppercase text-accent">
597
+ {access.operation}
598
+ </span>
599
+ ) : null}
600
+ <code className="mono break-all text-ink">
601
+ {access.keyspace ?? access.method ?? step.label ?? "repository call"}
602
+ </code>
603
+ </div>
604
+ </header>
605
+
606
+ <DetailSection title="Target">
607
+ <dl className="grid grid-cols-[minmax(6rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2">
608
+ <dt className="mono text-muted">Store</dt>
609
+ <dd>
610
+ {toStore ? (
611
+ <Link to={toStore} className="mono text-accent">
612
+ {access.store} →
613
+ </Link>
614
+ ) : (
615
+ <Ident value={access.store} className="text-muted" />
616
+ )}
617
+ </dd>
618
+ {access.method ? (
619
+ <>
620
+ <dt className="mono text-muted">Repository</dt>
621
+ <dd className="mono text-ink">{access.method}</dd>
622
+ </>
623
+ ) : null}
624
+ {aggregateId ? (
625
+ <>
626
+ <dt className="mono text-muted">Aggregate</dt>
627
+ <dd>
628
+ {toAggregate ? (
629
+ <Link to={toAggregate} className="mono text-accent">
630
+ {aggregateId} →
631
+ </Link>
632
+ ) : (
633
+ <Ident value={aggregateId} className="text-muted" />
634
+ )}
635
+ </dd>
636
+ </>
637
+ ) : null}
638
+ {keyspace?.value ? (
639
+ <>
640
+ <dt className="mono text-muted">Value</dt>
641
+ <dd className="mono text-ink">{keyspace.value}</dd>
642
+ </>
643
+ ) : null}
644
+ {keyspace?.ttl ? (
645
+ <>
646
+ <dt className="mono text-muted">TTL</dt>
647
+ <dd className="mono text-ink">{keyspace.ttl}</dd>
648
+ </>
649
+ ) : null}
650
+ </dl>
651
+ </DetailSection>
652
+
653
+ <DetailSection title="Repository call">
654
+ <Where step={step} flow={flow} />
655
+ </DetailSection>
656
+ {access.source ? (
657
+ <DetailSection title="Redis client call">
658
+ <SourceWhere where={access.source} flow={flow} />
659
+ </DetailSection>
660
+ ) : null}
661
+ </section>
662
+ );
663
+ }
664
+
306
665
  export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
307
666
  const decisions = step.ref ? (index.adrsByEvent.get(step.ref) ?? []) : [];
308
667
 
309
668
  return (
310
669
  <>
311
- <div className="mono mb-3 flex items-center gap-1.5 text-muted">
312
- <span>{step.from}</span>
313
- <span>&rarr;</span>
314
- <span>{step.to}</span>
315
- </div>
316
-
317
- <Frames step={step} flow={flow} />
670
+ <ExecutionContext step={step} flow={flow} />
318
671
 
319
672
  {/* A decision that names this step's event is the reason the step
320
673
  looks the way it does. It belongs next to the step, not three
@@ -341,6 +694,10 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
341
694
  <EventDetail step={step} flow={flow} />
342
695
  ) : step.kind === "rpc" ? (
343
696
  <RpcDetail step={step} flow={flow} />
697
+ ) : step.kind === "response" ? (
698
+ <ResponseDetail step={step} flow={flow} />
699
+ ) : step.storeAccess ? (
700
+ <StoreCallDetail step={step} flow={flow} />
344
701
  ) : (
345
702
  <>
346
703
  <div className="mono text-[13px]">
@@ -369,7 +726,10 @@ export function StepDetailBody({ step, flow }: { step: Step; flow: Flow }) {
369
726
  </>
370
727
  ) : null}
371
728
 
372
- {step.line && step.kind !== "call" ? (
729
+ {step.line &&
730
+ step.kind !== "call" &&
731
+ step.kind !== "rpc" &&
732
+ step.kind !== "response" ? (
373
733
  <>
374
734
  <Label>Observed at</Label>
375
735
  <Where step={step} flow={flow} />