@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/pages.tsx ADDED
@@ -0,0 +1,469 @@
1
+ import { CodeTab } from "./code-tab.js";
2
+ import {
3
+ Tabs,
4
+ TabsList,
5
+ TabsTrigger,
6
+ TabsContent,
7
+ } from "@fungi.computer/caps/components/tabs";
8
+ import { BonsaiBoard } from "./board.js";
9
+ import { Screen } from "@fungi.computer/caps/components/surface";
10
+ import { useWorkingNow } from "./working-now.js";
11
+ import { Card } from "@fungi.computer/caps/components/card";
12
+ import { Overview } from "./overview.js";
13
+ import { ChangeGroups, needsAttention } from "./change-rows.js";
14
+ import { RepoNavigation } from "./repo-navigation.js";
15
+ import { RepoSettings } from "./repo-settings.js";
16
+ /** Bonsai's pages: the repository list, a repository, and a commit. */
17
+ import { ChevronRight } from "lucide-react";
18
+ import { useEffect, useMemo, useState } from "react";
19
+ import type { RepoDetail } from "../shared/contract.js";
20
+ import {
21
+ useAgents,
22
+ useCommit,
23
+ useEvents,
24
+ useGraph,
25
+ useRepo,
26
+ useChanges,
27
+ type LiveState,
28
+ } from "./api.js";
29
+ import { DiffView } from "./views.js";
30
+ import { relativeTime, shortHash } from "./format.js";
31
+ import { CommitTree } from "./commit-tree.js";
32
+ import {
33
+ AgentMark,
34
+ AgentName,
35
+ agentColors,
36
+ Failure,
37
+ Loading,
38
+ type AgentColors,
39
+ } from "./parts.js";
40
+ import { href, Link, useRouter, type Route } from "./router.js";
41
+ import { IssuesPanel } from "./issues-panel.js";
42
+
43
+ function useColors(): AgentColors {
44
+ const agents = useAgents();
45
+ return useMemo(() => agentColors(agents.data?.agents), [agents.data]);
46
+ }
47
+
48
+ export type RepoRoute = Extract<Route, { page: "repo" }>;
49
+
50
+ function ChangesTab({ repo, colors }: { repo: string; colors: AgentColors }) {
51
+ const changes = useChanges(repo);
52
+ if (changes.isPending) return <Loading label="Loading changes" />;
53
+ if (changes.isError)
54
+ return (
55
+ <Failure
56
+ error={changes.error}
57
+ retry={() => {
58
+ void changes.refetch();
59
+ }}
60
+ />
61
+ );
62
+ if (changes.data.changes.length === 0) return null;
63
+ return (
64
+ <Card variant="outline" density="compact" data-tile="changes">
65
+ <div className="min-w-0 p-3">
66
+ <ChangeGroups changes={changes.data.changes} colors={colors} />
67
+ </div>
68
+ </Card>
69
+ );
70
+ }
71
+
72
+ function CommitsTab({
73
+ route,
74
+ detail,
75
+ colors,
76
+ }: {
77
+ route: RepoRoute;
78
+ detail: RepoDetail;
79
+ colors: AgentColors;
80
+ }) {
81
+ const graph = useGraph(detail.repo.parent ?? route.repo);
82
+ const actors = useAgents();
83
+ const { go } = useRouter();
84
+ const [filter, setFilter] = useState("all");
85
+ const [selected, setSelected] = useState<string | undefined>(route.commit);
86
+ const [wide, setWide] = useState(false);
87
+ useEffect(() => {
88
+ const media = matchMedia("(min-width: 1024px)");
89
+ const update = () => {
90
+ setWide(media.matches);
91
+ };
92
+ update();
93
+ media.addEventListener("change", update);
94
+ return () => {
95
+ media.removeEventListener("change", update);
96
+ };
97
+ }, []);
98
+ useEffect(() => {
99
+ setSelected(route.commit);
100
+ }, [route.commit]);
101
+ const selectedRef =
102
+ route.ref ?? (detail.repo.parent === null ? null : detail.defaultBranch);
103
+ const selectedLane = graph.data?.lanes.find(
104
+ (lane) => lane.repo === route.repo && lane.ref === selectedRef,
105
+ );
106
+ const nodes =
107
+ graph.data?.nodes.filter(
108
+ (node) =>
109
+ (selectedRef === null ||
110
+ (selectedLane !== undefined &&
111
+ node.lanes.includes(selectedLane.id))) &&
112
+ (filter === "all" ||
113
+ actors.data?.agents.find((actor) => actor.id === node.agent)?.kind ===
114
+ filter),
115
+ ) ?? [];
116
+ const current = nodes.find((node) => node.hash === selected) ?? nodes[0];
117
+ const open = (node: (typeof nodes)[number]) => {
118
+ if (wide) setSelected(node.hash);
119
+ else
120
+ go(
121
+ href({
122
+ page: "commit",
123
+ repo: node.origin ?? route.repo,
124
+ hash: node.hash,
125
+ }),
126
+ );
127
+ };
128
+ useEffect(() => {
129
+ const keydown = (event: KeyboardEvent) => {
130
+ if (
131
+ event.defaultPrevented ||
132
+ event.metaKey ||
133
+ event.ctrlKey ||
134
+ event.altKey ||
135
+ (event.target instanceof HTMLElement &&
136
+ event.target.closest(
137
+ "input,textarea,select,[contenteditable=true],[role=tab]",
138
+ ))
139
+ )
140
+ return;
141
+ const index = nodes.findIndex((node) => node.hash === current?.hash);
142
+ if (event.key === "j" || event.key === "k") {
143
+ const next =
144
+ nodes[
145
+ Math.max(
146
+ 0,
147
+ Math.min(nodes.length - 1, index + (event.key === "j" ? 1 : -1)),
148
+ )
149
+ ];
150
+ if (next) {
151
+ event.preventDefault();
152
+ setSelected(next.hash);
153
+ }
154
+ } else if (
155
+ event.key === "Enter" &&
156
+ current &&
157
+ !(
158
+ event.target instanceof HTMLElement &&
159
+ event.target.closest("a,button")
160
+ )
161
+ ) {
162
+ event.preventDefault();
163
+ go(
164
+ href({
165
+ page: "commit",
166
+ repo: current.origin ?? route.repo,
167
+ hash: current.hash,
168
+ }),
169
+ );
170
+ }
171
+ };
172
+ document.addEventListener("keydown", keydown);
173
+ return () => {
174
+ document.removeEventListener("keydown", keydown);
175
+ };
176
+ }, [nodes, current, go]);
177
+ if (detail.empty) return null;
178
+ if (graph.isPending || actors.isPending)
179
+ return <Loading label="Loading history" />;
180
+ if (graph.isError)
181
+ return (
182
+ <Failure
183
+ error={graph.error}
184
+ retry={() => {
185
+ void graph.refetch();
186
+ }}
187
+ />
188
+ );
189
+ if (actors.isError)
190
+ return (
191
+ <Failure
192
+ error={actors.error}
193
+ retry={() => {
194
+ void actors.refetch();
195
+ }}
196
+ />
197
+ );
198
+ const shown = { ...graph.data, nodes };
199
+ return (
200
+ <div className="grid items-start gap-3 lg:grid-cols-2">
201
+ {/* The repository nav already says "History"; no inner title repeats it. */}
202
+ <Screen data-tile="history" className="min-w-0">
203
+ <div className="min-w-0 p-3">
204
+ <Tabs value={filter} onValueChange={setFilter}>
205
+ <TabsList variant="segmented" aria-label="Commit authors">
206
+ <TabsTrigger value="all">All</TabsTrigger>
207
+ <TabsTrigger value="agent">Agents</TabsTrigger>
208
+ <TabsTrigger value="human">People</TabsTrigger>
209
+ </TabsList>
210
+ <TabsContent value={filter}>
211
+ <p
212
+ className="my-3 text-xs text-[var(--stipe-fg-muted)]"
213
+ title="J/K selects a commit; Enter opens it"
214
+ >
215
+ {selectedRef === null
216
+ ? "main + forks"
217
+ : `${route.repo}/${selectedRef}`}{" "}
218
+ · newest first
219
+ </p>
220
+ <CommitTree
221
+ graph={shown}
222
+ colors={colors}
223
+ selected={current?.hash}
224
+ onSelect={open}
225
+ />
226
+ </TabsContent>
227
+ </Tabs>
228
+ </div>
229
+ </Screen>
230
+ {wide && current ? (
231
+ <div className="sticky top-3 min-w-0">
232
+ <BonsaiBoard
233
+ columns={1}
234
+ rowHeight={0}
235
+ placements={[{ id: "commit", col: 0, row: 0, w: 1, h: 1 }]}
236
+ tiles={[
237
+ {
238
+ id: "commit",
239
+ title: "Commit",
240
+ autoHeight: true,
241
+ body: (
242
+ <CommitPage
243
+ repo={current.origin ?? route.repo}
244
+ hash={current.hash}
245
+ />
246
+ ),
247
+ },
248
+ ]}
249
+ />
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ export function RepoPage({
257
+ route,
258
+ live,
259
+ }: {
260
+ route: RepoRoute;
261
+ live: LiveState;
262
+ }) {
263
+ if (route.tab === "settings")
264
+ return <RepoSettings key={route.repo} repo={route.repo} />;
265
+ return <RepoContent route={route} live={live} />;
266
+ }
267
+
268
+ function RepoContent({ route, live }: { route: RepoRoute; live: LiveState }) {
269
+ const [controlsTarget, setControlsTarget] = useState<HTMLElement | null>(
270
+ null,
271
+ );
272
+ const detail = useRepo(route.repo);
273
+ const family = route.repo.split("--")[0] ?? route.repo;
274
+ const events = useEvents(family);
275
+ const recentPushes = useWorkingNow(events.data?.events ?? []);
276
+ const changeList = useChanges(family);
277
+ const openChanges =
278
+ changeList.data?.changes.filter(
279
+ (item) => item.state === "open" || item.state === "merging",
280
+ ).length ?? 0;
281
+ const colors = useColors();
282
+ if (detail.isPending) return <Loading label="Loading repository" />;
283
+ if (detail.isError)
284
+ return (
285
+ <Failure
286
+ error={detail.error}
287
+ retry={() => {
288
+ void detail.refetch();
289
+ }}
290
+ />
291
+ );
292
+ const tab = route.tab;
293
+ return (
294
+ <div className="bonsai-repo">
295
+ <RepoNavigation
296
+ detail={detail.data}
297
+ route={route}
298
+ working={recentPushes.length}
299
+ changes={openChanges}
300
+ needsAttention={changeList.data?.changes.some(needsAttention) ?? false}
301
+ controlsTarget={setControlsTarget}
302
+ />
303
+ <div className="min-w-0">
304
+ <section className="bonsai-main">
305
+ {tab === "code" ? (
306
+ <>
307
+ <CodeTab route={route} detail={detail.data} />
308
+ </>
309
+ ) : null}
310
+ {tab === "commits" ? (
311
+ <>
312
+ <CommitsTab route={route} detail={detail.data} colors={colors} />
313
+ </>
314
+ ) : null}
315
+ {tab === "overview" ? (
316
+ <>
317
+ <Overview
318
+ route={route}
319
+ detail={detail.data}
320
+ colors={colors}
321
+ live={live}
322
+ controlsTarget={controlsTarget}
323
+ />
324
+ </>
325
+ ) : null}
326
+ {tab === "changes" ? (
327
+ <>
328
+ <ChangesTab repo={route.repo} colors={colors} />
329
+ </>
330
+ ) : null}
331
+ {tab === "issues" ? (
332
+ <>
333
+ <IssuesPanel
334
+ repo={route.repo}
335
+ refName={route.ref ?? detail.data.defaultBranch}
336
+ open={route.file}
337
+ />
338
+ </>
339
+ ) : null}
340
+ </section>
341
+ </div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ export function CommitPage({ repo, hash }: { repo: string; hash: string }) {
347
+ const detail = useCommit(repo, hash);
348
+ const changes = useChanges(repo.split("--")[0] ?? repo);
349
+ const openChange = changes.data?.changes.find(
350
+ (change) =>
351
+ change.sourceRepo === repo &&
352
+ change.head === hash &&
353
+ change.state === "open",
354
+ );
355
+ const colors = useColors();
356
+ if (detail.isPending) return <Loading label="Loading commit" />;
357
+ if (detail.isError)
358
+ return (
359
+ <Failure
360
+ error={detail.error}
361
+ retry={() => {
362
+ void detail.refetch();
363
+ }}
364
+ />
365
+ );
366
+ const { commit, agent, diff } = detail.data;
367
+ const [title = "", ...rest] = commit.message.trim().split("\n");
368
+ const family = repo.split("--")[0] ?? repo;
369
+ return (
370
+ <div className="bonsai-commit">
371
+ <nav className="bonsai-crumbs" aria-label="Repository">
372
+ <Link
373
+ to={href({
374
+ page: "repo",
375
+ repo: family,
376
+ tab: "commits",
377
+ ref: null,
378
+ path: "",
379
+ file: null,
380
+ })}
381
+ >
382
+ {family}
383
+ </Link>
384
+ {repo !== family ? (
385
+ <span>
386
+ <ChevronRight aria-hidden="true" size={13} />
387
+ <Link
388
+ to={href({
389
+ page: "repo",
390
+ repo,
391
+ tab: "commits",
392
+ ref: null,
393
+ path: "",
394
+ file: null,
395
+ })}
396
+ >
397
+ {repo}
398
+ </Link>
399
+ </span>
400
+ ) : null}
401
+ </nav>
402
+ <header className="bonsai-commit-head">
403
+ <h1 className="caps-card-title">{title}</h1>
404
+ {rest.join("\n").trim() ? (
405
+ <pre className="bonsai-commit-body">{rest.join("\n").trim()}</pre>
406
+ ) : null}
407
+ <p className="bonsai-commit-meta">
408
+ {agent ? <AgentMark name={agent} colors={colors} /> : null}
409
+ <span>
410
+ {agent ? (
411
+ <AgentName name={agent} colors={colors} />
412
+ ) : (
413
+ commit.author.name
414
+ )}
415
+ <span className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
416
+ {" "}
417
+ committed {relativeTime(commit.committedAt * 1000)}
418
+ </span>
419
+ </span>
420
+ <code className="font-mono text-sm text-[var(--stipe-fg-muted)] bonsai-hash">
421
+ {shortHash(commit.hash)}
422
+ </code>
423
+ {commit.parents[0] ? (
424
+ <span className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
425
+ parent{" "}
426
+ <Link
427
+ to={href({ page: "commit", repo, hash: commit.parents[0] })}
428
+ >
429
+ <code>{shortHash(commit.parents[0])}</code>
430
+ </Link>
431
+ </span>
432
+ ) : null}
433
+ </p>
434
+ <p className="bonsai-diff-summary">
435
+ <span>
436
+ {diff.files.length + diff.omittedFiles}{" "}
437
+ {diff.files.length + diff.omittedFiles === 1 ? "file" : "files"}{" "}
438
+ changed
439
+ </span>
440
+ <span className="font-mono text-success bonsai-add">
441
+ +{diff.additions}
442
+ </span>
443
+ <span className="font-mono text-error bonsai-remove">
444
+ −{diff.deletions}
445
+ </span>
446
+ </p>
447
+ </header>
448
+ {openChange ? (
449
+ <Link
450
+ to={href({
451
+ page: "change",
452
+ repo: openChange.repo,
453
+ number: openChange.number,
454
+ })}
455
+ >
456
+ Open Change ›
457
+ </Link>
458
+ ) : null}
459
+ {diff.files.map((file) => (
460
+ <DiffView key={file.path} file={file} />
461
+ ))}
462
+ {diff.omittedFiles > 0 ? (
463
+ <p className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
464
+ {diff.omittedFiles} more files not shown.
465
+ </p>
466
+ ) : null}
467
+ </div>
468
+ );
469
+ }