@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
package/web/parts.tsx ADDED
@@ -0,0 +1,381 @@
1
+ /** Shared pieces composed from Caps: agent marks, the activity feed, sparklines. */
2
+ import { Button } from "@fungi.computer/caps/components/button";
3
+ import { Monogram } from "@fungi.computer/caps/components/monogram";
4
+ import { Loader } from "@fungi.computer/caps/components/loader";
5
+ import { EmptyState } from "@fungi.computer/caps/blocks/empty-state";
6
+ import { useListMotion } from "@fungi.computer/caps/lib/reorder-motion";
7
+ import { curveMonotoneX } from "@visx/curve";
8
+ import { scaleLinear } from "@visx/scale";
9
+ import { AreaClosed, LinePath } from "@visx/shape";
10
+ import { useRef, type ReactNode } from "react";
11
+ import type { Activity, Agent } from "../shared/contract.js";
12
+ import { ApiError } from "./api.js";
13
+ import { agentColor, relativeTime, shortActor, shortHash } from "./format.js";
14
+ import { href, Link } from "./router.js";
15
+
16
+ export type AgentColors = ReadonlyMap<
17
+ string,
18
+ { color: string; kind: Agent["kind"] }
19
+ >;
20
+
21
+ export function agentColors(agents: readonly Agent[] | undefined): AgentColors {
22
+ return new Map(
23
+ (agents ?? []).map((agent) => [
24
+ agent.name,
25
+ { color: agent.color, kind: agent.kind },
26
+ ]),
27
+ );
28
+ }
29
+
30
+ /**
31
+ * The actor's readable label. Bonsai never records a human member's display
32
+ * name (their id is their identity, see `callerActor`), so a known human
33
+ * actor reads as a short label instead of the raw host-issued id. Agents,
34
+ * and any actor this view hasn't resolved yet, keep their recorded name.
35
+ */
36
+ export function actorLabel(name: string, colors: AgentColors): string {
37
+ return colors.get(name)?.kind === "human" ? shortActor(name) : name;
38
+ }
39
+
40
+ export function AgentMark({
41
+ name,
42
+ colors,
43
+ size = "default",
44
+ }: {
45
+ name: string;
46
+ colors: AgentColors;
47
+ size?: "default" | "lg";
48
+ }) {
49
+ return (
50
+ <Monogram
51
+ name={actorLabel(name, colors)}
52
+ seed={name}
53
+ size={size}
54
+ data-agent-slot={colors.get(name)?.color}
55
+ />
56
+ );
57
+ }
58
+
59
+ export function AgentName({
60
+ name,
61
+ colors,
62
+ }: {
63
+ name: string;
64
+ colors: AgentColors;
65
+ }) {
66
+ return (
67
+ <span className="font-semibold bonsai-agent-name">
68
+ <svg
69
+ className="inline-block mr-1"
70
+ width="8"
71
+ height="8"
72
+ viewBox="0 0 8 8"
73
+ aria-hidden="true"
74
+ >
75
+ <circle
76
+ cx="4"
77
+ cy="4"
78
+ r="4"
79
+ fill={agentColor(colors.get(name)?.color)}
80
+ />
81
+ </svg>
82
+ {actorLabel(name, colors)}
83
+ </span>
84
+ );
85
+ }
86
+
87
+ function ownerOf(repo: string): string | null {
88
+ const split = repo.indexOf("--");
89
+ return split === -1 ? null : repo.slice(split + 2);
90
+ }
91
+
92
+ function familyOf(repo: string): string {
93
+ return repo.split("--")[0] ?? repo;
94
+ }
95
+
96
+ function describe(event: Activity, colors: AgentColors): ReactNode {
97
+ const who = <AgentName name={event.actor} colors={colors} />;
98
+ switch (event.type) {
99
+ case "push": {
100
+ const count = event.data.commits.length;
101
+ if (count === 0)
102
+ return (
103
+ <>
104
+ {who} brought{" "}
105
+ <span className="font-mono text-sm bonsai-ref">{event.repo}</span>{" "}
106
+ up to date
107
+ </>
108
+ );
109
+ const ref =
110
+ event.data.updates[0]?.ref.replace(/^refs\/heads\//u, "") ?? "";
111
+ const where =
112
+ ownerOf(event.repo) === null
113
+ ? event.repo
114
+ : `${ownerOf(event.repo) ?? ""}/${ref}`;
115
+ return (
116
+ <>
117
+ {who} pushed {count === 1 ? "a commit" : `${String(count)} commits`}{" "}
118
+ to{" "}
119
+ <span className="font-mono text-sm bonsai-ref">
120
+ {ownerOf(event.repo) === null ? `${event.repo}/${ref}` : where}
121
+ </span>
122
+ </>
123
+ );
124
+ }
125
+ case "fork":
126
+ return (
127
+ <>
128
+ {who} forked{" "}
129
+ <span className="font-mono text-sm bonsai-ref">{event.repo}</span>
130
+ </>
131
+ );
132
+ case "merge":
133
+ return (
134
+ <>
135
+ {who} merged{" "}
136
+ <span className="font-mono text-sm bonsai-ref">
137
+ {ownerOf(event.data.change.sourceRepo) ??
138
+ event.data.change.sourceRepo}
139
+ /{event.data.change.sourceRef}
140
+ </span>{" "}
141
+ into{" "}
142
+ <span className="font-mono text-sm bonsai-ref">{event.data.ref}</span>
143
+ </>
144
+ );
145
+ case "merge.conflict":
146
+ return (
147
+ <>
148
+ {who} tried to merge{" "}
149
+ <span className="font-mono text-sm bonsai-ref">
150
+ {ownerOf(event.data.change.sourceRepo) ??
151
+ event.data.change.sourceRepo}
152
+ /{event.data.change.sourceRef}
153
+ </span>
154
+ : conflicts in{" "}
155
+ {event.data.paths.length === 1
156
+ ? event.data.paths[0]
157
+ : `${String(event.data.paths.length)} files`}
158
+ </>
159
+ );
160
+ case "change.opened":
161
+ case "change.updated":
162
+ case "change.approved":
163
+ case "change.checks_requested":
164
+ case "change.checked":
165
+ case "change.closed":
166
+ return (
167
+ <>
168
+ {who} {event.type.slice(7).replaceAll("_", " ")}{" "}
169
+ <Link
170
+ to={href({
171
+ page: "change",
172
+ repo: event.data.change.repo,
173
+ number: event.data.change.number,
174
+ })}
175
+ >
176
+ Change #{event.data.change.number}
177
+ </Link>
178
+ {event.data.name
179
+ ? ` · ${event.data.name}: ${event.data.status ?? ""}`
180
+ : ""}
181
+ </>
182
+ );
183
+ case "repo.created":
184
+ return (
185
+ <>
186
+ {who} created{" "}
187
+ <span className="font-mono text-sm bonsai-ref">{event.repo}</span>
188
+ </>
189
+ );
190
+ }
191
+ }
192
+
193
+ export function ActivityFeed({
194
+ events,
195
+ colors,
196
+ fresh,
197
+ showRepo = false,
198
+ }: {
199
+ events: readonly Activity[];
200
+ colors: AgentColors;
201
+ fresh: ReadonlySet<number>;
202
+ showRepo?: boolean;
203
+ }) {
204
+ const list = useRef<HTMLOListElement>(null);
205
+ useListMotion(
206
+ list,
207
+ events.map((event) => String(event.id)),
208
+ );
209
+ if (events.length === 0) return null;
210
+ return (
211
+ <ol className="bonsai-feed" ref={list}>
212
+ {events.map((event) => (
213
+ <li
214
+ key={event.id}
215
+ data-reorder-key={String(event.id)}
216
+ className={
217
+ fresh.has(event.id)
218
+ ? "bonsai-feed-item bonsai-feed-fresh"
219
+ : "bonsai-feed-item"
220
+ }
221
+ >
222
+ <div className="min-w-0">
223
+ <p
224
+ className="truncate"
225
+ title={
226
+ event.type === "push"
227
+ ? event.data.commits[0]?.message
228
+ : undefined
229
+ }
230
+ >
231
+ {event.type === "push" && event.data.commits.length > 0 ? (
232
+ <>
233
+ <AgentName name={event.actor} colors={colors} /> ·{" "}
234
+ {event.data.commits[0]?.message}
235
+ {event.data.commits.length > 1
236
+ ? ` (+${String(event.data.commits.length - 1)} commits)`
237
+ : ""}
238
+ </>
239
+ ) : (
240
+ describe(event, colors)
241
+ )}
242
+ </p>
243
+ <p
244
+ className="truncate text-xs text-[var(--stipe-fg-muted)]"
245
+ title={
246
+ event.type === "push" ? event.data.paths.join(", ") : undefined
247
+ }
248
+ >
249
+ {event.type === "push" && event.data.commits[0] ? (
250
+ <>
251
+ <Link
252
+ to={href({
253
+ page: "commit",
254
+ repo: event.repo,
255
+ hash: event.data.commits[0].hash,
256
+ })}
257
+ >
258
+ {shortHash(event.data.commits[0].hash)}
259
+ </Link>
260
+ {event.data.paths.length > 0
261
+ ? ` · ${event.data.paths[0] ?? ""}${event.data.paths.length > 1 ? ` +${String(event.data.paths.length - 1)}` : ""}`
262
+ : ""}
263
+ {" · "}
264
+ </>
265
+ ) : null}
266
+ {showRepo ? (
267
+ <>
268
+ <Link
269
+ to={href({
270
+ page: "repo",
271
+ repo: familyOf(event.repo),
272
+ tab: "overview",
273
+ ref: null,
274
+ path: "",
275
+ file: null,
276
+ })}
277
+ >
278
+ {familyOf(event.repo)}
279
+ </Link>
280
+ {" · "}
281
+ </>
282
+ ) : null}
283
+ <time dateTime={new Date(event.at).toISOString()}>
284
+ {relativeTime(event.at)}
285
+ </time>
286
+ </p>
287
+ </div>
288
+ </li>
289
+ ))}
290
+ </ol>
291
+ );
292
+ }
293
+
294
+ /**
295
+ * Pushes per hour over the last day, as a soft area. A single active bucket
296
+ * (or none) has no trend to show, only a stray mark, so it renders nothing.
297
+ */
298
+ export function Sparkline({
299
+ values,
300
+ width = 120,
301
+ height = 28,
302
+ }: {
303
+ values: readonly number[];
304
+ width?: number;
305
+ height?: number;
306
+ }) {
307
+ const active = values.filter((value) => value > 0).length;
308
+ if (active < 2) return null;
309
+ const max = Math.max(1, ...values);
310
+ const x = scaleLinear({
311
+ domain: [0, Math.max(1, values.length - 1)],
312
+ range: [1, width - 1],
313
+ });
314
+ const y = scaleLinear({ domain: [0, max], range: [height - 2, 3] });
315
+ const points = values.map((value, index) => ({ index, value }));
316
+ return (
317
+ <svg
318
+ className="bonsai-spark"
319
+ width={width}
320
+ height={height}
321
+ role="img"
322
+ aria-label={`${String(values.reduce((a, b) => a + b, 0))} pushes in the last day`}
323
+ >
324
+ <AreaClosed
325
+ data={points}
326
+ x={(point) => x(point.index)}
327
+ y={(point) => y(point.value)}
328
+ yScale={y}
329
+ curve={curveMonotoneX}
330
+ className="bonsai-spark-area"
331
+ />
332
+ <LinePath
333
+ data={points}
334
+ x={(point) => x(point.index)}
335
+ y={(point) => y(point.value)}
336
+ curve={curveMonotoneX}
337
+ className="bonsai-spark-line"
338
+ />
339
+ </svg>
340
+ );
341
+ }
342
+
343
+ export function Loading({ label }: { label: string }) {
344
+ return (
345
+ <div className="bonsai-loading">
346
+ <Loader label={label} size="sm" />
347
+ </div>
348
+ );
349
+ }
350
+
351
+ export function Failure({
352
+ error,
353
+ retry,
354
+ }: {
355
+ error: unknown;
356
+ retry?: () => void;
357
+ }) {
358
+ return (
359
+ <EmptyState
360
+ variant="quiet"
361
+ headingLevel={3}
362
+ title={
363
+ error instanceof ApiError && error.status === 404
364
+ ? "Not found"
365
+ : retry
366
+ ? "Taking longer than usual"
367
+ : "Couldn't load this"
368
+ }
369
+ description={
370
+ error instanceof Error ? error.message : "Try again in a moment."
371
+ }
372
+ actions={
373
+ retry ? (
374
+ <Button size="sm" variant="outline" onClick={retry}>
375
+ Retry
376
+ </Button>
377
+ ) : undefined
378
+ }
379
+ />
380
+ );
381
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M9 23V9m0 4c0 4 14 2 14 9" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="9" cy="9" r="3" fill="currentColor"/><circle cx="9" cy="23" r="3" fill="currentColor"/><circle cx="23" cy="22" r="3" fill="currentColor"/></svg>
@@ -0,0 +1,256 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Button } from "@fungi.computer/caps/components/button";
3
+ import { CopyField } from "@fungi.computer/caps/components/copy-field";
4
+ import { CountBadge } from "@fungi.computer/caps/components/count-badge";
5
+ import { Status } from "@fungi.computer/caps/components/status";
6
+ import { NavigationRail } from "@fungi.computer/caps/components/navigation-rail";
7
+ import {
8
+ Popover,
9
+ PopoverAnchor,
10
+ } from "@fungi.computer/caps/components/popover";
11
+ import { Select } from "@fungi.computer/caps/components/select";
12
+ import { inspectIssueTree } from "@fungi.computer/issues";
13
+ import type { RepoDetail } from "../shared/contract.js";
14
+ import { useIssueFiles } from "./api.js";
15
+ import { href, Link, useRouter, type Route } from "./router.js";
16
+
17
+ const sections = [
18
+ ["overview", "Overview", "o"],
19
+ ["code", "Code", "c"],
20
+ ["commits", "History", "h"],
21
+ ["changes", "Changes", "x"],
22
+ ["issues", "Issues", "i"],
23
+ ["settings", "Settings", "s"],
24
+ ] as const;
25
+
26
+ function IssueCount({ repo, refName }: { repo: string; refName: string }) {
27
+ const files = useIssueFiles(repo, refName);
28
+ if (!files.data) return null;
29
+ const count = inspectIssueTree(files.data.files).frontier.length;
30
+ return count > 0 ? <CountBadge count={count} /> : null;
31
+ }
32
+
33
+ export function RepoNavigation({
34
+ detail,
35
+ route,
36
+ working,
37
+ changes,
38
+ needsAttention,
39
+ controlsTarget,
40
+ }: {
41
+ detail: RepoDetail;
42
+ route: Extract<Route, { page: "repo" }>;
43
+ working: number;
44
+ changes: number;
45
+ needsAttention: boolean;
46
+ controlsTarget: (element: HTMLDivElement | null) => void;
47
+ }) {
48
+ const { go } = useRouter();
49
+ const [cloneOpen, setCloneOpen] = useState(false);
50
+ const [copyRequest, setCopyRequest] = useState(0);
51
+ const desktopClone = useRef<HTMLDivElement>(null);
52
+ const phoneClone = useRef<HTMLDivElement>(null);
53
+ const anchor = useRef<HTMLDivElement>(null);
54
+ const cloneButton = useRef<HTMLButtonElement>(null);
55
+ const refName = route.ref ?? detail.defaultBranch;
56
+ const navigate = (tab: Extract<Route, { page: "repo" }>["tab"]) => {
57
+ go(href({ ...route, tab, file: null, path: "" }));
58
+ };
59
+ useEffect(() => {
60
+ let chordAt = 0;
61
+ const keydown = (event: KeyboardEvent) => {
62
+ if (
63
+ event.defaultPrevented ||
64
+ event.ctrlKey ||
65
+ event.metaKey ||
66
+ event.altKey ||
67
+ event.repeat
68
+ )
69
+ return;
70
+ const target = event.target;
71
+ if (
72
+ target instanceof HTMLElement &&
73
+ (target.isContentEditable ||
74
+ target.closest("input,textarea,select,[role=textbox]"))
75
+ )
76
+ return;
77
+ const key = event.key.toLowerCase();
78
+ if (key === "escape" && cloneOpen) {
79
+ setCloneOpen(false);
80
+ cloneButton.current?.focus();
81
+ return;
82
+ }
83
+ const section = sections.find((item) => item[2] === key);
84
+ if (chordAt && Date.now() - chordAt < 1200 && section) {
85
+ event.preventDefault();
86
+ chordAt = 0;
87
+ navigate(section[0]);
88
+ } else if (key === "g") chordAt = Date.now();
89
+ else {
90
+ chordAt = 0;
91
+ if (key === "c") {
92
+ event.preventDefault();
93
+ if (matchMedia("(max-width: 767px)").matches) setCloneOpen(true);
94
+ setCopyRequest((value) => value + 1);
95
+ }
96
+ }
97
+ };
98
+ document.addEventListener("keydown", keydown);
99
+ return () => {
100
+ document.removeEventListener("keydown", keydown);
101
+ };
102
+ });
103
+ useEffect(() => {
104
+ if (copyRequest === 0) return;
105
+ const field = matchMedia("(max-width: 767px)").matches
106
+ ? phoneClone.current
107
+ : desktopClone.current;
108
+ field?.querySelector("button")?.click();
109
+ }, [copyRequest]);
110
+ useEffect(() => {
111
+ if (!cloneOpen) return;
112
+ const dismiss = (event: PointerEvent) => {
113
+ if (
114
+ event.target instanceof Node &&
115
+ !anchor.current?.contains(event.target)
116
+ )
117
+ setCloneOpen(false);
118
+ };
119
+ document.addEventListener("pointerdown", dismiss);
120
+ return () => {
121
+ document.removeEventListener("pointerdown", dismiss);
122
+ };
123
+ }, [cloneOpen]);
124
+ return (
125
+ <>
126
+ <header
127
+ className="flex min-w-0 flex-wrap items-center gap-3"
128
+ aria-label="Repository header"
129
+ >
130
+ <div className="flex min-w-0 flex-1 basis-full flex-wrap items-baseline gap-x-2 gap-y-1 md:basis-0">
131
+ <Link to="/" className="text-sm text-[var(--stipe-fg-muted)]">
132
+ Bonsai /
133
+ </Link>
134
+ <h1 className="caps-card-title">{detail.repo.name}</h1>
135
+ {detail.repo.description ? (
136
+ <p className="text-sm text-[var(--stipe-fg-muted)]">
137
+ {detail.repo.description}
138
+ </p>
139
+ ) : null}
140
+ {detail.repo.parent ? (
141
+ <span className="text-sm text-[var(--stipe-fg-muted)]">
142
+ fork of{" "}
143
+ <Link
144
+ to={href({
145
+ ...route,
146
+ repo: detail.repo.parent,
147
+ tab: "overview",
148
+ })}
149
+ >
150
+ {detail.repo.parent}
151
+ </Link>
152
+ </span>
153
+ ) : null}
154
+ </div>
155
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
156
+ {working > 0 ? (
157
+ <Status
158
+ aria-label="Recent repository pushes"
159
+ title="Pushed in the last 15 minutes"
160
+ size="compact"
161
+ appearance="chip"
162
+ tone="neutral"
163
+ label={`${String(working)} pushed recently`}
164
+ />
165
+ ) : null}
166
+ <div className="w-32 shrink-0">
167
+ <Select
168
+ aria-label="Branch"
169
+ className="w-32"
170
+ value={refName}
171
+ onChange={(event) => {
172
+ go(
173
+ href({
174
+ ...route,
175
+ ref: event.target.value,
176
+ path: "",
177
+ file: null,
178
+ }),
179
+ );
180
+ }}
181
+ >
182
+ {detail.refs
183
+ .filter((item) => item.name.startsWith("refs/heads/"))
184
+ .map((item) => (
185
+ <option key={item.name} value={item.name.slice(11)}>
186
+ {item.name.slice(11)}
187
+ </option>
188
+ ))}
189
+ </Select>
190
+ </div>
191
+ <div className="hidden max-w-sm md:block" ref={desktopClone}>
192
+ <CopyField value={detail.cloneUrl} label="Clone" />
193
+ </div>
194
+ <PopoverAnchor className="md:hidden" ref={anchor}>
195
+ <Button
196
+ ref={cloneButton}
197
+ variant="outline"
198
+ size="sm"
199
+ aria-expanded={cloneOpen}
200
+ aria-controls="repo-clone"
201
+ onClick={() => {
202
+ setCloneOpen(!cloneOpen);
203
+ }}
204
+ >
205
+ Clone
206
+ </Button>
207
+ <Popover
208
+ open={cloneOpen}
209
+ side="bottom"
210
+ id="repo-clone"
211
+ className="w-72 max-w-[calc(100vw-2rem)]"
212
+ role="region"
213
+ aria-label="Clone repository"
214
+ >
215
+ <div ref={phoneClone}>
216
+ <CopyField value={detail.cloneUrl} />
217
+ </div>
218
+ </Popover>
219
+ </PopoverAnchor>
220
+ </div>
221
+ </header>
222
+ <div className="flex min-w-0 flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between">
223
+ <NavigationRail
224
+ className="min-w-0 sm:flex-1"
225
+ aria-label="Repository sections"
226
+ >
227
+ {sections.map(([tab, label, key]) => (
228
+ <Link
229
+ key={tab}
230
+ className="inline-flex items-center gap-2"
231
+ to={href({ ...route, tab, path: "", file: null })}
232
+ aria-current={route.tab === tab ? "page" : undefined}
233
+ title={`G ${key.toUpperCase()}`}
234
+ >
235
+ {label}
236
+ {tab === "changes" && changes > 0 ? (
237
+ <CountBadge
238
+ count={changes}
239
+ tone={needsAttention ? "warning" : "neutral"}
240
+ />
241
+ ) : null}
242
+ {tab === "issues" ? (
243
+ <IssueCount repo={route.repo} refName={refName} />
244
+ ) : null}
245
+ </Link>
246
+ ))}
247
+ </NavigationRail>
248
+ <div
249
+ className="self-end sm:self-auto"
250
+ ref={controlsTarget}
251
+ hidden={route.tab !== "overview"}
252
+ />
253
+ </div>
254
+ </>
255
+ );
256
+ }