@fungi.computer/bonsai 0.0.0-stage → 0.1.0

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 (133) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +848 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,647 @@
1
+ import { Button } from "@fungi.computer/caps/components/button";
2
+ /**
3
+ * The network: every branch of the canonical repository and every agent fork
4
+ * as a lane, commits as beads coloured by the agent that pushed them, forks
5
+ * peeling off and merges folding back. Time runs left to right; the graph fills
6
+ * its width, zooms, pans, and new commits arrive with a pop.
7
+ */
8
+ import { IconButton } from "@fungi.computer/caps/components/icon-button";
9
+ import { Maximize2, ZoomIn, ZoomOut } from "lucide-react";
10
+ import {
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ type PointerEvent as ReactPointerEvent,
17
+ type RefObject,
18
+ } from "react";
19
+ import type { Graph, GraphLane, GraphNode } from "../shared/contract.js";
20
+ import { agentColor, relativeTime, shortHash } from "./format.js";
21
+ import type { AgentColors } from "./parts.js";
22
+
23
+ const ROW = 52;
24
+ const PAD_X = 32;
25
+ const PAD_TOP = 34;
26
+ const AXIS = 34;
27
+ const LABEL_ROOM = 260;
28
+ const NARROW = 560;
29
+ const NARROW_LABEL_ROOM = 24;
30
+ const NARROW_MIN_FIT_STEP = 14;
31
+ const MIN_STEP = 18;
32
+ const MAX_FIT_STEP = 96;
33
+ const MIN_FIT_STEP = 34;
34
+ const LABEL_CHARS = 38;
35
+ const NARROW_LABEL_CHARS = 20;
36
+
37
+ export type Placed = Readonly<{
38
+ node: GraphNode;
39
+ x: number;
40
+ y: number;
41
+ row: number;
42
+ column: number;
43
+ }>;
44
+
45
+ export type Lane = GraphLane & { readonly row: number };
46
+
47
+ /** Order commits so every parent precedes its children, oldest first. */
48
+ function topoOrder(nodes: readonly GraphNode[]): GraphNode[] {
49
+ const byHash = new Map(nodes.map((node) => [node.hash, node]));
50
+ const seen = new Set<string>();
51
+ const ordered: GraphNode[] = [];
52
+ const ascending = [...nodes].sort((a, b) => a.committedAt - b.committedAt);
53
+ for (const start of ascending) {
54
+ const stack: { node: GraphNode; next: number }[] = [
55
+ { node: start, next: 0 },
56
+ ];
57
+ while (stack.length > 0) {
58
+ const top = stack[stack.length - 1];
59
+ if (!top) break;
60
+ if (seen.has(top.node.hash) && top.next === 0) {
61
+ stack.pop();
62
+ continue;
63
+ }
64
+ const parentHash = top.node.parents[top.next];
65
+ if (parentHash !== undefined) {
66
+ top.next += 1;
67
+ const parent = byHash.get(parentHash);
68
+ if (parent && !seen.has(parent.hash))
69
+ stack.push({ node: parent, next: 0 });
70
+ continue;
71
+ }
72
+ stack.pop();
73
+ if (!seen.has(top.node.hash)) {
74
+ seen.add(top.node.hash);
75
+ ordered.push(top.node);
76
+ }
77
+ }
78
+ }
79
+ return ordered;
80
+ }
81
+
82
+ /**
83
+ * A commit lives on the lane of the repository it was first pushed to, so a
84
+ * fork's work stays on the fork's lane after it is merged; otherwise on the
85
+ * first lane that contains it.
86
+ */
87
+ function homeLane(
88
+ node: GraphNode,
89
+ lanes: ReadonlyMap<string, Lane>,
90
+ ): Lane | undefined {
91
+ const own =
92
+ node.origin === null
93
+ ? undefined
94
+ : node.lanes.find((id) => lanes.get(id)?.repo === node.origin);
95
+ return lanes.get(own ?? node.lanes[0] ?? "");
96
+ }
97
+
98
+ export type GraphLayout = Readonly<{
99
+ placed: ReadonlyMap<string, Placed>;
100
+ order: readonly Placed[];
101
+ lanes: readonly Lane[];
102
+ home: ReadonlyMap<string, Lane>;
103
+ step: number;
104
+ width: number;
105
+ height: number;
106
+ }>;
107
+
108
+ function laneY(row: number): number {
109
+ return PAD_TOP + row * ROW;
110
+ }
111
+
112
+ export function layoutGraph(
113
+ graph: Graph,
114
+ step: number,
115
+ labelRoom = LABEL_ROOM,
116
+ ): GraphLayout {
117
+ const lanes = graph.lanes.map((lane, row): Lane => ({ ...lane, row }));
118
+ const byId = new Map(lanes.map((lane) => [lane.id, lane]));
119
+ const placed = new Map<string, Placed>();
120
+ const home = new Map<string, Lane>();
121
+ const order: Placed[] = [];
122
+ topoOrder(graph.nodes).forEach((node, column) => {
123
+ const lane = homeLane(node, byId);
124
+ const row = lane?.row ?? 0;
125
+ if (lane) home.set(node.hash, lane);
126
+ const item: Placed = {
127
+ node,
128
+ row,
129
+ column,
130
+ x: PAD_X + column * step,
131
+ y: laneY(row),
132
+ };
133
+ placed.set(node.hash, item);
134
+ order.push(item);
135
+ });
136
+ return {
137
+ placed,
138
+ order,
139
+ lanes,
140
+ home,
141
+ step,
142
+ width: PAD_X * 2 + Math.max(0, order.length - 1) * step + labelRoom,
143
+ height: laneY(Math.max(0, lanes.length - 1)) + AXIS + 18,
144
+ };
145
+ }
146
+
147
+ /** An SVG path from commands and coordinates. */
148
+ function path(...parts: readonly (string | number)[]): string {
149
+ return parts.join(" ");
150
+ }
151
+
152
+ function edgePath(
153
+ parent: Placed,
154
+ child: Placed,
155
+ merge: boolean,
156
+ step: number,
157
+ ): string {
158
+ const { x: px, y: py } = parent;
159
+ const { x: cx, y: cy } = child;
160
+ if (py === cy) return path("M", px, py, "L", cx, cy);
161
+ const bend = Math.min(Math.max(step, 30), cx - px);
162
+ if (merge) {
163
+ // Travel along the source lane, then fold into the target.
164
+ const start = cx - bend;
165
+ return path(
166
+ "M",
167
+ px,
168
+ py,
169
+ "L",
170
+ start,
171
+ py,
172
+ "C",
173
+ start + bend * 0.55,
174
+ py,
175
+ cx - bend * 0.45,
176
+ cy,
177
+ cx,
178
+ cy,
179
+ );
180
+ }
181
+ // Peel off right after the parent, then run along the new lane.
182
+ const end = px + bend;
183
+ return path(
184
+ "M",
185
+ px,
186
+ py,
187
+ "C",
188
+ px + bend * 0.55,
189
+ py,
190
+ end - bend * 0.45,
191
+ cy,
192
+ end,
193
+ cy,
194
+ "L",
195
+ cx,
196
+ cy,
197
+ );
198
+ }
199
+
200
+ function clip(text: string, limit: number): string {
201
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
202
+ }
203
+
204
+ export type NetworkGraphProps = Readonly<{
205
+ graph: Graph;
206
+ colors: AgentColors;
207
+ /** Commits that arrived while this page was open. */
208
+ fresh: ReadonlySet<string>;
209
+ onSelect: (node: GraphNode, lane: GraphLane) => void;
210
+ onLaneSelect?: (lane: GraphLane) => void;
211
+ laneDetails?: ReadonlyMap<string, string>;
212
+ shortLabels?: boolean;
213
+ }>;
214
+
215
+ function useWidth(element: RefObject<HTMLElement | null>): number {
216
+ const [width, setWidth] = useState(0);
217
+ useLayoutEffect(() => {
218
+ const target = element.current;
219
+ if (!target) return undefined;
220
+ setWidth(target.clientWidth);
221
+ const observer = new ResizeObserver(([entry]) => {
222
+ if (entry) setWidth(entry.contentRect.width);
223
+ });
224
+ observer.observe(target);
225
+ return () => {
226
+ observer.disconnect();
227
+ };
228
+ }, [element]);
229
+ return width;
230
+ }
231
+
232
+ export function NetworkGraph({
233
+ graph,
234
+ colors,
235
+ fresh,
236
+ onSelect,
237
+ onLaneSelect,
238
+ laneDetails,
239
+ shortLabels = false,
240
+ }: NetworkGraphProps) {
241
+ const scroller = useRef<HTMLDivElement>(null);
242
+ const width = useWidth(scroller);
243
+ const [zoom, setZoom] = useState(1);
244
+ const [scrollLeft, setScrollLeft] = useState(0);
245
+ const [hover, setHover] = useState<Placed | null>(null);
246
+ const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
247
+
248
+ const narrow = width > 0 && width < NARROW;
249
+ const labelRoom = narrow ? NARROW_LABEL_ROOM : LABEL_ROOM;
250
+ const labelChars = narrow ? NARROW_LABEL_CHARS : LABEL_CHARS;
251
+ const columns = Math.max(1, graph.nodes.length - 1);
252
+ const fitStep = Math.min(
253
+ MAX_FIT_STEP,
254
+ Math.max(
255
+ narrow ? NARROW_MIN_FIT_STEP : MIN_FIT_STEP,
256
+ (width - PAD_X * 2 - labelRoom) / columns,
257
+ ),
258
+ );
259
+ const step = Math.max(MIN_STEP, fitStep * zoom);
260
+ const layout = useMemo(
261
+ () => layoutGraph(graph, step, labelRoom),
262
+ [graph, step, labelRoom],
263
+ );
264
+ const heads = useMemo(() => {
265
+ const byHead = new Map<string, Lane[]>();
266
+ for (const lane of layout.lanes) {
267
+ const list = byHead.get(lane.head) ?? [];
268
+ list.push(lane);
269
+ byHead.set(lane.head, list);
270
+ }
271
+ return byHead;
272
+ }, [layout.lanes]);
273
+
274
+ // Keep the newest work in view as history grows.
275
+ const newest = graph.nodes[0]?.hash ?? "";
276
+ const newestX = layout.order.at(-1)?.x ?? 0;
277
+ useEffect(() => {
278
+ const element = scroller.current;
279
+ if (element)
280
+ element.scrollTo({
281
+ left: Math.max(0, newestX + labelRoom * 0.85 - element.clientWidth),
282
+ behavior: "instant",
283
+ });
284
+ }, [newest, newestX, labelRoom, width]);
285
+
286
+ const laneInk = (lane: GraphLane | undefined) => {
287
+ const owner = lane?.owner ?? null;
288
+ return owner === null
289
+ ? "var(--stipe-fg-muted)"
290
+ : agentColor(colors.get(owner)?.color);
291
+ };
292
+ const beadInk = (item: Placed) =>
293
+ item.node.agent === null
294
+ ? laneInk(layout.home.get(item.node.hash))
295
+ : agentColor(colors.get(item.node.agent)?.color);
296
+
297
+ type Edge = {
298
+ key: string;
299
+ d: string;
300
+ ink: string;
301
+ fresh: boolean;
302
+ merge: boolean;
303
+ };
304
+ const edges: Edge[] = [];
305
+ const forkPoints = new Set<string>();
306
+ for (const child of layout.order) {
307
+ child.node.parents.forEach((parentHash, index) => {
308
+ const parent = layout.placed.get(parentHash);
309
+ if (!parent) return;
310
+ const merge = index > 0;
311
+ if (!merge && parent.row !== child.row) forkPoints.add(parent.node.hash);
312
+ edges.push({
313
+ key: `${parentHash}-${child.node.hash}`,
314
+ d: edgePath(parent, child, merge, layout.step),
315
+ ink: laneInk(
316
+ layout.home.get(merge ? parent.node.hash : child.node.hash),
317
+ ),
318
+ fresh: fresh.has(child.node.hash),
319
+ merge,
320
+ });
321
+ });
322
+ }
323
+ // A fork with no commits of its own yet: a dashed stub off its base commit.
324
+ const stubs: { key: string; d: string; ink: string }[] = [];
325
+ for (const lane of layout.lanes) {
326
+ const head = layout.placed.get(lane.head);
327
+ if (!head || head.row === lane.row) continue;
328
+ const y = laneY(lane.row);
329
+ const reach = Math.max(layout.step, 30);
330
+ stubs.push({
331
+ key: lane.id,
332
+ d: path(
333
+ "M",
334
+ head.x,
335
+ head.y,
336
+ "C",
337
+ head.x + reach * 0.55,
338
+ head.y,
339
+ head.x + reach * 0.45,
340
+ y,
341
+ head.x + reach,
342
+ y,
343
+ "L",
344
+ head.x + reach * 2,
345
+ y,
346
+ ),
347
+ ink: laneInk(lane),
348
+ });
349
+ }
350
+
351
+ // Select visible labels, including the final commit only when it fits.
352
+ const ticks: { item: Placed; label: string }[] = [];
353
+ const labels = new Set<string>();
354
+ for (const item of layout.order) {
355
+ const label = relativeTime(item.node.committedAt * 1000);
356
+ const previous = ticks.at(-1);
357
+ const margin = Math.max(32, label.length * 3.5);
358
+ if (
359
+ item.x - scrollLeft < margin ||
360
+ item.x - scrollLeft > width - margin ||
361
+ labels.has(label) ||
362
+ (previous && item.x - previous.item.x < 110)
363
+ )
364
+ continue;
365
+ ticks.push({ item, label });
366
+ labels.add(label);
367
+ }
368
+ const axisY = layout.height - AXIS / 2 - 4;
369
+
370
+ const select = (item: Placed) => {
371
+ const lane = layout.home.get(item.node.hash);
372
+ if (lane) onSelect(item.node, lane);
373
+ };
374
+
375
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
376
+ if (event.pointerType !== "mouse" || event.button !== 0) return;
377
+ drag.current = {
378
+ x: event.clientX,
379
+ left: event.currentTarget.scrollLeft,
380
+ moved: false,
381
+ };
382
+ };
383
+ const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
384
+ const state = drag.current;
385
+ if (!state) return;
386
+ const delta = event.clientX - state.x;
387
+ if (Math.abs(delta) > 3) state.moved = true;
388
+ event.currentTarget.scrollLeft = state.left - delta;
389
+ };
390
+ const endDrag = () => {
391
+ drag.current = null;
392
+ };
393
+
394
+ useEffect(() => {
395
+ const element = scroller.current;
396
+ if (!element) return undefined;
397
+ const onWheel = (event: WheelEvent) => {
398
+ if (event.ctrlKey || event.metaKey) {
399
+ event.preventDefault();
400
+ setZoom((current) =>
401
+ Math.min(
402
+ 4,
403
+ Math.max(0.25, current * (event.deltaY < 0 ? 1.12 : 0.89)),
404
+ ),
405
+ );
406
+ }
407
+ };
408
+ element.addEventListener("wheel", onWheel, { passive: false });
409
+ return () => {
410
+ element.removeEventListener("wheel", onWheel);
411
+ };
412
+ }, []);
413
+
414
+ return (
415
+ <div className="bonsai-network">
416
+ <div className="bonsai-network-tools" role="group" aria-label="Zoom">
417
+ <IconButton
418
+ aria-label="Zoom out"
419
+ icon={<ZoomOut />}
420
+ disabled={step <= MIN_STEP}
421
+ onClick={() => {
422
+ setZoom((current) => Math.max(0.25, current / 1.4));
423
+ }}
424
+ />
425
+ <IconButton
426
+ aria-label="Fit history"
427
+ icon={<Maximize2 />}
428
+ active={zoom === 1}
429
+ onClick={() => {
430
+ setZoom(1);
431
+ }}
432
+ />
433
+ <IconButton
434
+ aria-label="Zoom in"
435
+ icon={<ZoomIn />}
436
+ disabled={zoom >= 4}
437
+ onClick={() => {
438
+ setZoom((current) => Math.min(4, current * 1.4));
439
+ }}
440
+ />
441
+ </div>
442
+ <ol className="bonsai-network-gutter" style={{ height: layout.height }}>
443
+ {layout.lanes.map((lane) => (
444
+ <li
445
+ key={lane.id}
446
+ className="bonsai-network-lane"
447
+ title={`${lane.repo}/${lane.ref}`}
448
+ >
449
+ <span
450
+ className="bonsai-lane-swatch"
451
+ style={{ background: laneInk(lane) }}
452
+ aria-hidden="true"
453
+ />
454
+ {onLaneSelect ? (
455
+ <Button
456
+ variant="ghost"
457
+ size="xs"
458
+ className="h-auto min-w-max py-1"
459
+ onClick={() => {
460
+ onLaneSelect(lane);
461
+ }}
462
+ >
463
+ <span className="flex min-w-0 flex-col items-start">
464
+ <span className="max-w-full truncate">
465
+ {lane.owner ?? lane.repo} / {lane.ref}
466
+ </span>
467
+ <span className="text-xs text-[var(--stipe-fg-muted)]">
468
+ {laneDetails?.get(lane.repo)}
469
+ </span>
470
+ </span>
471
+ </Button>
472
+ ) : (
473
+ <>
474
+ <span className="bonsai-lane-owner min-w-0 truncate">
475
+ {shortLabels
476
+ ? (lane.owner ?? lane.ref)
477
+ : (lane.owner ?? lane.repo)}
478
+ </span>
479
+ {!shortLabels ? (
480
+ <span className="bonsai-lane-ref">{lane.ref}</span>
481
+ ) : null}
482
+ </>
483
+ )}
484
+ </li>
485
+ ))}
486
+ </ol>
487
+ <div
488
+ className="bonsai-network-scroll"
489
+ ref={scroller}
490
+ onScroll={(event) => {
491
+ setScrollLeft(event.currentTarget.scrollLeft);
492
+ }}
493
+ onPointerDown={onPointerDown}
494
+ onPointerMove={onPointerMove}
495
+ onPointerUp={endDrag}
496
+ onPointerLeave={endDrag}
497
+ onClickCapture={(event) => {
498
+ if (drag.current?.moved) event.stopPropagation();
499
+ }}
500
+ >
501
+ <svg
502
+ width={Math.max(layout.width, width)}
503
+ height={layout.height}
504
+ role="img"
505
+ aria-label={`${String(graph.nodes.length)} commits across ${String(graph.lanes.length)} lanes`}
506
+ >
507
+ <g className="bonsai-network-tracks">
508
+ {layout.lanes.map((lane) => (
509
+ <line
510
+ key={lane.id}
511
+ x1={0}
512
+ x2={Math.max(layout.width, width)}
513
+ y1={laneY(lane.row)}
514
+ y2={laneY(lane.row)}
515
+ />
516
+ ))}
517
+ </g>
518
+ <g className="bonsai-network-axis">
519
+ {ticks.map(({ item, label }) => (
520
+ <g key={item.node.hash}>
521
+ <line x1={item.x} x2={item.x} y1={axisY - 14} y2={axisY - 9} />
522
+ <text x={item.x} y={axisY + 4} textAnchor="middle">
523
+ {label}
524
+ </text>
525
+ </g>
526
+ ))}
527
+ </g>
528
+ <g fill="none" strokeWidth={2.75} strokeLinecap="round">
529
+ {stubs.map((stub) => (
530
+ <path
531
+ key={stub.key}
532
+ d={stub.d}
533
+ stroke={stub.ink}
534
+ strokeDasharray="1 6"
535
+ />
536
+ ))}
537
+ {edges.map((edge) => (
538
+ <path
539
+ key={edge.key}
540
+ d={edge.d}
541
+ stroke={edge.ink}
542
+ pathLength={1}
543
+ className={
544
+ edge.fresh ? "bonsai-edge bonsai-edge-fresh" : "bonsai-edge"
545
+ }
546
+ data-merge={edge.merge ? "" : undefined}
547
+ />
548
+ ))}
549
+ </g>
550
+ <g>
551
+ {layout.order.map((item) => {
552
+ const headLanes = heads.get(item.node.hash) ?? [];
553
+ const isHead = headLanes.some((lane) => lane.row === item.row);
554
+ const ink = beadInk(item);
555
+ const merge = item.node.parents.length > 1;
556
+ const isFresh = fresh.has(item.node.hash);
557
+ const classes = ["bonsai-bead"];
558
+ if (isFresh) classes.push("bonsai-bead-fresh");
559
+ if (hover?.node.hash === item.node.hash)
560
+ classes.push("bonsai-bead-active");
561
+ return (
562
+ <g
563
+ key={item.node.hash}
564
+ transform={path("translate(", item.x, item.y, ")")}
565
+ className={classes.join(" ")}
566
+ tabIndex={0}
567
+ role="button"
568
+ aria-label={`${item.node.message} by ${item.node.agent ?? item.node.author}`}
569
+ onPointerEnter={() => {
570
+ setHover(item);
571
+ }}
572
+ onPointerLeave={() => {
573
+ setHover(null);
574
+ }}
575
+ onFocus={() => {
576
+ setHover(item);
577
+ }}
578
+ onBlur={() => {
579
+ setHover(null);
580
+ }}
581
+ onClick={() => {
582
+ select(item);
583
+ }}
584
+ onKeyDown={(event) => {
585
+ if (event.key === "Enter" || event.key === " ") {
586
+ event.preventDefault();
587
+ select(item);
588
+ }
589
+ }}
590
+ >
591
+ {isHead ? (
592
+ <circle className="bonsai-bead-halo" r={13} fill={ink} />
593
+ ) : null}
594
+ {isFresh && isHead ? (
595
+ <circle
596
+ className="bonsai-recent-head"
597
+ r={10}
598
+ stroke={ink}
599
+ fill="none"
600
+ />
601
+ ) : null}
602
+ <circle r={16} fill="transparent" />
603
+ {forkPoints.has(item.node.hash) || merge ? (
604
+ <circle
605
+ className="bonsai-bead-ring"
606
+ r={isHead ? 12.5 : 10}
607
+ fill="none"
608
+ stroke={ink}
609
+ strokeWidth={1.5}
610
+ />
611
+ ) : null}
612
+ <circle
613
+ className="bonsai-bead-core"
614
+ r={isHead ? 8 : 6}
615
+ fill={merge ? "var(--color-base-100)" : ink}
616
+ stroke={ink}
617
+ strokeWidth={merge ? 3 : 0}
618
+ />
619
+ {isHead && !narrow ? (
620
+ <text className="bonsai-head-label" x={18} y={4}>
621
+ {clip(item.node.message, labelChars)}
622
+ </text>
623
+ ) : null}
624
+ </g>
625
+ );
626
+ })}
627
+ </g>
628
+ </svg>
629
+ {hover ? (
630
+ <div
631
+ className="bg-neutral text-neutral-content rounded-box p-2 text-xs bonsai-network-tip"
632
+ style={{ left: hover.x, top: hover.y + 22 }}
633
+ role="tooltip"
634
+ >
635
+ <strong>{hover.node.message}</strong>
636
+ <span>
637
+ <i style={{ background: beadInk(hover) }} aria-hidden="true" />
638
+ {hover.node.agent ?? hover.node.author} ·{" "}
639
+ {shortHash(hover.node.hash)} ·{" "}
640
+ {relativeTime(hover.node.committedAt * 1000)}
641
+ </span>
642
+ </div>
643
+ ) : null}
644
+ </div>
645
+ </div>
646
+ );
647
+ }