@fungi.computer/bonsai 0.0.0-stage → 0.0.1

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 (132) hide show
  1. package/CHANGELOG.md +17 -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 +835 -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/read-copy-calls.test.ts +691 -0
  90. package/test/read-copy-composer.test.ts +388 -0
  91. package/test/read-copy-lifetimes.test.ts +214 -0
  92. package/test/read-copy-rpc.config.jsonc +12 -0
  93. package/test/read-copy-rpc.native.mjs +47 -0
  94. package/test/read-copy-rpc.worker.ts +92 -0
  95. package/test/recent-activity.test.ts +62 -0
  96. package/test/repository-deletion.test.ts +500 -0
  97. package/test/seed-resume.test.mjs +91 -0
  98. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  99. package/test/storyboard-save-template.test.mjs +545 -0
  100. package/test/ui-errors.test.ts +976 -0
  101. package/test/workflow-store.test.ts +115 -0
  102. package/tsconfig.json +36 -0
  103. package/turbo.jsonc +25 -0
  104. package/vite.config.ts +19 -0
  105. package/vitest.config.ts +5 -0
  106. package/web/api.ts +224 -0
  107. package/web/app-build-panel.tsx +171 -0
  108. package/web/board.tsx +259 -0
  109. package/web/bonsai.css +642 -0
  110. package/web/change-page.tsx +715 -0
  111. package/web/change-rows.tsx +135 -0
  112. package/web/code-tab.tsx +267 -0
  113. package/web/commit-tree.tsx +152 -0
  114. package/web/format.ts +54 -0
  115. package/web/home-page.tsx +274 -0
  116. package/web/index.html +17 -0
  117. package/web/insight-data.ts +76 -0
  118. package/web/insight-tiles.tsx +534 -0
  119. package/web/insights.tsx +250 -0
  120. package/web/issues-panel.tsx +305 -0
  121. package/web/main.tsx +72 -0
  122. package/web/network-graph.tsx +647 -0
  123. package/web/overview.tsx +339 -0
  124. package/web/pages.tsx +469 -0
  125. package/web/parts.tsx +381 -0
  126. package/web/public/favicon.svg +1 -0
  127. package/web/repo-navigation.tsx +256 -0
  128. package/web/repo-settings.tsx +145 -0
  129. package/web/router.tsx +151 -0
  130. package/web/transport.ts +91 -0
  131. package/web/views.tsx +151 -0
  132. package/web/working-now.tsx +98 -0
@@ -0,0 +1,361 @@
1
+ /** Files projections consume the composer's bounded store; they never open a frame. */
2
+ import * as z from "zod";
3
+ import {
4
+ fileContent,
5
+ fileList,
6
+ treeListing,
7
+ type Commit,
8
+ } from "@fungi.computer/agar/roles/files";
9
+ import { decodeText, isBinary } from "./diff.js";
10
+ import { AppMethodRefusal } from "@fungi.computer/agar/backend";
11
+ import { projectionStore } from "./history.js";
12
+ import { readRepositoryRefs } from "./repository-copy.js";
13
+ import type { Bonsai } from "./service.js";
14
+
15
+ const requestSchema = z.discriminatedUnion("kind", [
16
+ z.strictObject({
17
+ kind: z.literal("list"),
18
+ repo: z.string(),
19
+ ref: z.string(),
20
+ }),
21
+ z.strictObject({
22
+ kind: z.literal("tree"),
23
+ repo: z.string(),
24
+ ref: z.string(),
25
+ path: z.string(),
26
+ }),
27
+ z.strictObject({
28
+ kind: z.literal("file"),
29
+ repo: z.string(),
30
+ ref: z.string(),
31
+ path: z.string(),
32
+ }),
33
+ ]);
34
+ export type FilesCopyRequest = z.infer<typeof requestSchema>;
35
+ const registrySchema = z.strictObject({
36
+ epoch: z.number().int().nonnegative(),
37
+ entries: z
38
+ .array(z.strictObject({ key: z.string(), request: requestSchema }))
39
+ .max(3000),
40
+ });
41
+ const storedFile = fileContent.omit({
42
+ text: true,
43
+ pending: true,
44
+ version: true,
45
+ });
46
+ const MAX_VIEW_BYTES = 512_000;
47
+
48
+ /** One key is one foreground page. Existing views remain discoverable after invalidation. */
49
+ export function filesCopyRequests(
50
+ bonsai: Bonsai,
51
+ repo: string,
52
+ ): FilesCopyRequest[] {
53
+ return registry(bonsai, repo).entries.map((entry) => entry.request);
54
+ }
55
+
56
+ function registryKey(bonsai: Bonsai, repo: string): string {
57
+ return JSON.stringify([repo, bonsai.state.repositoryGeneration(repo)]);
58
+ }
59
+
60
+ function registry(bonsai: Bonsai, repo: string) {
61
+ const saved = bonsai.state.copies
62
+ .open("bonsai-files-keys", registrySchema)
63
+ .read(registryKey(bonsai, repo));
64
+ return saved.kind === "ready" ? saved.value : { epoch: 0, entries: [] };
65
+ }
66
+
67
+ /** Invoke before ANY provider await, including repository/Change preparation. */
68
+ export function invalidateFilesCopies(bonsai: Bonsai, repo: string): void {
69
+ const lists = bonsai.state.copies.open("bonsai-files-list", fileList);
70
+ const trees = bonsai.state.copies.open("bonsai-files-tree", treeListing);
71
+ const files = bonsai.state.copies.open("bonsai-files-file", storedFile);
72
+ const saved = registry(bonsai, repo);
73
+ bonsai.state.copies
74
+ .open("bonsai-files-keys", registrySchema)
75
+ .write(registryKey(bonsai, repo), null, {
76
+ ...saved,
77
+ epoch: saved.epoch + 1,
78
+ });
79
+ for (const { key, request } of saved.entries) {
80
+ if (/^[0-9a-f]{40}$/u.test(request.ref)) continue;
81
+ if (request.kind === "list") lists.stale(key);
82
+ else if (request.kind === "tree") trees.stale(key);
83
+ else files.stale(key);
84
+ }
85
+ }
86
+
87
+ async function remember(
88
+ bonsai: Bonsai,
89
+ request: FilesCopyRequest,
90
+ ): Promise<string> {
91
+ const normalized = requestSchema.parse(request);
92
+ const generation = bonsai.state.repositoryGeneration(request.repo);
93
+ const identity = JSON.stringify([generation, normalized]);
94
+ const found = registry(bonsai, request.repo).entries.find(
95
+ (entry) => JSON.stringify([generation, entry.request]) === identity,
96
+ );
97
+ if (found !== undefined) return found.key;
98
+ // Public file paths may exceed R0's 1024-character storage-key bound.
99
+ const digest = await crypto.subtle.digest(
100
+ "SHA-256",
101
+ new TextEncoder().encode(identity),
102
+ );
103
+ const key = Array.from(new Uint8Array(digest), (byte) =>
104
+ byte.toString(16).padStart(2, "0"),
105
+ ).join("");
106
+ if (bonsai.state.repositoryGeneration(request.repo) !== generation)
107
+ throw new AppMethodRefusal(
108
+ "unavailable",
109
+ "Repository changed during Files key registration.",
110
+ );
111
+ const saved = registry(bonsai, request.repo);
112
+ if (!saved.entries.some((entry) => entry.key === key)) {
113
+ if (saved.entries.length === 3000)
114
+ throw new AppMethodRefusal("invalid", "Too many Files read-copy keys.");
115
+ bonsai.state.copies
116
+ .open("bonsai-files-keys", registrySchema)
117
+ .write(registryKey(bonsai, request.repo), null, {
118
+ ...saved,
119
+ entries: [...saved.entries, { key, request: normalized }],
120
+ });
121
+ }
122
+ return key;
123
+ }
124
+
125
+ function head(bonsai: Bonsai, request: FilesCopyRequest): string {
126
+ if (bonsai.state.repo(request.repo) === null)
127
+ throw new AppMethodRefusal("not_found", "No such repository");
128
+ if (/^[0-9a-f]{40}$/u.test(request.ref)) return request.ref;
129
+ const repository = readRepositoryRefs(bonsai, request.repo);
130
+ const resolved = repository.refs.find(
131
+ (ref) =>
132
+ ref.name === request.ref ||
133
+ ref.name === `refs/heads/${request.ref}` ||
134
+ ref.name === `refs/tags/${request.ref}`,
135
+ );
136
+ if (resolved === undefined)
137
+ throw new AppMethodRefusal("not_found", "Unknown ref");
138
+ return resolved.hash;
139
+ }
140
+
141
+ /** Refresh only this view, sharing the supplied frame budget and immutable objects. */
142
+ export async function refreshFilesCopy(
143
+ bonsai: Bonsai,
144
+ request: FilesCopyRequest,
145
+ ): Promise<void> {
146
+ const generation = bonsai.state.repositoryGeneration(request.repo);
147
+ const key = await remember(bonsai, request);
148
+ try {
149
+ const epoch = registry(bonsai, request.repo).epoch;
150
+ const hash = head(bonsai, request);
151
+ const checkPublication = () => {
152
+ if (
153
+ bonsai.state.repositoryGeneration(request.repo) !== generation ||
154
+ (!/^[0-9a-f]{40}$/u.test(request.ref) &&
155
+ registry(bonsai, request.repo).epoch !== epoch) ||
156
+ head(bonsai, request) !== hash
157
+ )
158
+ throw new AppMethodRefusal(
159
+ "unavailable",
160
+ "Files copy changed during rebuild.",
161
+ );
162
+ };
163
+ const store = projectionStore(bonsai);
164
+ const commit = await store.commit(request.repo, hash);
165
+ if (commit === null) throw new AppMethodRefusal("not_found", "Unknown ref");
166
+ if (request.kind === "list") {
167
+ // Preserve the ordinary listing's breadth-first ordering and 3000-path cap,
168
+ // but consume provider operations serially within the shared frame.
169
+ const paths: string[] = [];
170
+ let level = [{ hash: commit.treeHash, prefix: "" }];
171
+ while (level.length > 0 && paths.length < 3000) {
172
+ const next: typeof level = [];
173
+ for (const item of level) {
174
+ const entries = await store.tree(request.repo, item.hash);
175
+ for (const entry of entries ?? []) {
176
+ if (entry.type === "commit") continue;
177
+ const path = `${item.prefix}${entry.name}`;
178
+ if (entry.type === "tree") {
179
+ paths.push(`${path}/`);
180
+ next.push({ hash: entry.hash, prefix: `${path}/` });
181
+ } else paths.push(path);
182
+ }
183
+ }
184
+ level = next;
185
+ }
186
+ checkPublication();
187
+ bonsai.state.copies.open("bonsai-files-list", fileList).write(key, hash, {
188
+ ref: request.ref,
189
+ commit,
190
+ paths: paths.slice(0, 3000),
191
+ truncated: paths.length >= 3000,
192
+ });
193
+ return;
194
+ }
195
+ const target = await targetAt(store, request.repo, commit, request.path);
196
+ if (request.kind === "tree") {
197
+ if (target?.type !== "tree")
198
+ throw new AppMethodRefusal("not_found", "No such directory");
199
+ const entries = [
200
+ ...((await store.tree(request.repo, target.hash)) ?? []),
201
+ ].sort((a, b) =>
202
+ a.type === b.type
203
+ ? a.name.localeCompare(b.name)
204
+ : a.type === "tree"
205
+ ? -1
206
+ : 1,
207
+ );
208
+ checkPublication();
209
+ bonsai.state.copies
210
+ .open("bonsai-files-tree", treeListing)
211
+ .write(key, hash, {
212
+ ref: request.ref,
213
+ commit,
214
+ path: request.path,
215
+ entries,
216
+ });
217
+ return;
218
+ }
219
+ if (target === null) {
220
+ checkPublication();
221
+ bonsai.state.copies
222
+ .open("bonsai-files-file", storedFile)
223
+ .write(key, hash, {
224
+ ref: request.ref,
225
+ path: request.path,
226
+ commit: hash,
227
+ blob: null,
228
+ size: 0,
229
+ binary: false,
230
+ });
231
+ return;
232
+ }
233
+ if (target.type !== "blob")
234
+ throw new AppMethodRefusal("not_found", "No such file");
235
+ const bytes = await store.blob(request.repo, target.hash);
236
+ if (bytes === null) throw new AppMethodRefusal("not_found", "No such file");
237
+ const decoded = bytes.length > MAX_VIEW_BYTES ? null : decodeText(bytes);
238
+ checkPublication();
239
+ bonsai.state.copies.open("bonsai-files-file", storedFile).write(key, hash, {
240
+ ref: request.ref,
241
+ path: request.path,
242
+ commit: hash,
243
+ blob: target.hash,
244
+ size: bytes.length,
245
+ binary: isBinary(bytes) || decoded?.kind === "invalid-utf8",
246
+ });
247
+ } catch (error) {
248
+ if (error instanceof AppMethodRefusal && error.code === "not_found") {
249
+ const saved = registry(bonsai, request.repo);
250
+ bonsai.state.copies
251
+ .open("bonsai-files-keys", registrySchema)
252
+ .write(registryKey(bonsai, request.repo), null, {
253
+ ...saved,
254
+ entries: saved.entries.filter((entry) => entry.key !== key),
255
+ });
256
+ }
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ type TreeTarget = Readonly<{ hash: string; type: "tree" | "blob" }>;
262
+
263
+ async function targetAt(
264
+ store: ReturnType<typeof projectionStore>,
265
+ repo: string,
266
+ commit: Commit,
267
+ path: string,
268
+ ) {
269
+ let target: TreeTarget = {
270
+ type: "tree",
271
+ hash: commit.treeHash,
272
+ };
273
+ for (const segment of path.split("/").filter(Boolean)) {
274
+ if (target.type !== "tree")
275
+ throw new AppMethodRefusal("not_found", "Not a repository directory");
276
+ const entries = await store.tree(repo, target.hash);
277
+ if (entries === null)
278
+ throw new AppMethodRefusal("not_found", "Directory object is missing");
279
+ const entry = entries.find((item) => item.name === segment);
280
+ if (entry === undefined) return null;
281
+ if (entry.type === "commit")
282
+ throw new AppMethodRefusal("not_found", "Unsupported repository entry");
283
+ target = { hash: entry.hash, type: entry.type };
284
+ }
285
+ return target;
286
+ }
287
+
288
+ export async function listFilesCopy(
289
+ bonsai: Bonsai,
290
+ input: { repo: string; ref: string },
291
+ ) {
292
+ const request: FilesCopyRequest = { kind: "list", ...input };
293
+ const key = await remember(bonsai, request);
294
+ const copies = bonsai.state.copies.open("bonsai-files-list", fileList);
295
+ let saved = copies.read(key);
296
+ if (saved.kind !== "ready" || saved.head !== head(bonsai, request)) {
297
+ await refreshFilesCopy(bonsai, request);
298
+ saved = copies.read(key);
299
+ }
300
+ if (saved.kind !== "ready")
301
+ throw new AppMethodRefusal("unavailable", "Files copy is rebuilding.");
302
+ return saved.value;
303
+ }
304
+
305
+ export async function listTreeCopy(
306
+ bonsai: Bonsai,
307
+ input: { repo: string; ref: string; path: string },
308
+ ) {
309
+ const request: FilesCopyRequest = { kind: "tree", ...input };
310
+ const key = await remember(bonsai, request);
311
+ const copies = bonsai.state.copies.open("bonsai-files-tree", treeListing);
312
+ let saved = copies.read(key);
313
+ if (saved.kind !== "ready" || saved.head !== head(bonsai, request)) {
314
+ await refreshFilesCopy(bonsai, request);
315
+ saved = copies.read(key);
316
+ }
317
+ if (saved.kind !== "ready")
318
+ throw new AppMethodRefusal("unavailable", "Files copy is rebuilding.");
319
+ return saved.value;
320
+ }
321
+
322
+ export async function readFileCopy(
323
+ bonsai: Bonsai,
324
+ input: { repo: string; ref: string; path: string },
325
+ ) {
326
+ const request: FilesCopyRequest = { kind: "file", ...input };
327
+ const key = await remember(bonsai, request);
328
+ const copies = bonsai.state.copies.open("bonsai-files-file", storedFile);
329
+ let saved = copies.read(key);
330
+ if (
331
+ saved.kind !== "ready" ||
332
+ saved.head !== head(bonsai, request) ||
333
+ (saved.value.blob !== null &&
334
+ !saved.value.binary &&
335
+ saved.value.size <= MAX_VIEW_BYTES &&
336
+ bonsai.state.copies.readBlob(saved.value.blob) === null)
337
+ ) {
338
+ await refreshFilesCopy(bonsai, request);
339
+ saved = copies.read(key);
340
+ }
341
+ if (saved.kind !== "ready")
342
+ throw new AppMethodRefusal("unavailable", "File copy is rebuilding.");
343
+ const value = saved.value;
344
+ const bytes =
345
+ value.blob === null || value.binary || value.size > MAX_VIEW_BYTES
346
+ ? null
347
+ : bonsai.state.copies.readBlob(value.blob);
348
+ if (
349
+ value.blob !== null &&
350
+ !value.binary &&
351
+ value.size <= MAX_VIEW_BYTES &&
352
+ bytes === null
353
+ )
354
+ throw new AppMethodRefusal("unavailable", "File copy is rebuilding.");
355
+ const decoded = bytes === null ? null : decodeText(bytes);
356
+ return {
357
+ ...value,
358
+ version: null,
359
+ text: decoded?.kind === "text" ? decoded.text : null,
360
+ };
361
+ }
@@ -0,0 +1,125 @@
1
+ import { defineAppGill, type AppBackendGill } from "@fungi.computer/agar/gills";
2
+ import { changePage, repoSummary } from "../shared/contract.js";
3
+ import {
4
+ commitPage,
5
+ readCopyRebuilding,
6
+ } from "@fungi.computer/agar/roles/files";
7
+ import {
8
+ changeStatusLabel,
9
+ changeStatusTone,
10
+ relativeTime,
11
+ } from "../shared/presentation.js";
12
+
13
+ const repositoryParam = {
14
+ key: "repo",
15
+ label: "Repository",
16
+ kind: "repository" as const,
17
+ required: true,
18
+ options: "repos.options",
19
+ };
20
+ const repository = repoSummary.shape.name;
21
+
22
+ export const openChangesGill = defineAppGill({
23
+ id: "open-changes",
24
+ title: "Open Changes",
25
+ source: "changes.list",
26
+ limit: 20,
27
+ sizes: ["M", "L"],
28
+ params: [repositoryParam],
29
+ input: ({ params, limit, after }): ReturnType<AppBackendGill["input"]> => {
30
+ const input = { repo: repository.parse(params.repo), state: "open", limit };
31
+ return after === undefined ? input : { ...input, after };
32
+ },
33
+ rows: (output) => {
34
+ const identity = { viewId: "open-changes", title: "Open Changes" };
35
+ if (readCopyRebuilding.safeParse(output).success)
36
+ return {
37
+ ...identity,
38
+ state: "error",
39
+ rows: [],
40
+ recovery: { kind: "open" },
41
+ error:
42
+ "Saved changes are rebuilding. Open Bonsai to finish the rebuild.",
43
+ };
44
+ const page = changePage.parse(output);
45
+ if (page.changes.length === 0)
46
+ return { ...identity, state: "empty", rows: [] };
47
+ const rows = page.changes.map((change) => ({
48
+ id: String(change.number),
49
+ title: change.title,
50
+ detail: `#${String(change.number)} · ${change.sourceRef} → ${change.targetRef}`,
51
+ status: {
52
+ label: changeStatusLabel(change.status),
53
+ tone: changeStatusTone[change.status],
54
+ },
55
+ open: {
56
+ params: [
57
+ { key: "page", value: "change" },
58
+ { key: "repo", value: change.repo },
59
+ { key: "number", value: String(change.number) },
60
+ ],
61
+ },
62
+ }));
63
+ return page.next === undefined
64
+ ? { ...identity, state: "full", rows }
65
+ : { ...identity, state: "full", rows, next: page.next };
66
+ },
67
+ });
68
+
69
+ export const recentCommitsGill = defineAppGill({
70
+ id: "recent-commits",
71
+ title: "Recent Commits",
72
+ source: "commits.list",
73
+ limit: 20,
74
+ sizes: ["M", "L"],
75
+ params: [
76
+ repositoryParam,
77
+ {
78
+ key: "branch",
79
+ label: "Branch",
80
+ kind: "branch",
81
+ required: false,
82
+ options: "branches.options",
83
+ },
84
+ ],
85
+ input: ({ params, limit, after }): ReturnType<AppBackendGill["input"]> => {
86
+ const input = { repo: repository.parse(params.repo), limit };
87
+ const selected =
88
+ params.branch === undefined ? input : { ...input, ref: params.branch };
89
+ return after === undefined ? selected : { ...selected, after };
90
+ },
91
+ rows: (output, { params }) => {
92
+ const identity = { viewId: "recent-commits", title: "Recent Commits" };
93
+ if (readCopyRebuilding.safeParse(output).success)
94
+ return {
95
+ ...identity,
96
+ state: "error",
97
+ rows: [],
98
+ recovery: { kind: "open" },
99
+ error:
100
+ "Saved commits are rebuilding. Open Bonsai to finish the rebuild.",
101
+ };
102
+ const page = commitPage.parse(output);
103
+ const repo = repository.parse(params.repo);
104
+ if (page.commits.length === 0)
105
+ return { ...identity, state: "empty", rows: [] };
106
+ const rows = page.commits.map(({ commit, agent }) => ({
107
+ id: commit.hash,
108
+ title:
109
+ commit.message.trim().length === 0
110
+ ? commit.hash.slice(0, 8)
111
+ : (commit.message.split("\n")[0] ?? commit.hash.slice(0, 8)),
112
+ detail: `${commit.hash.slice(0, 8)} · ${agent ?? commit.author.name} · ${relativeTime(commit.committedAt * 1000)} · ${page.ref}`,
113
+ open: {
114
+ params: [
115
+ { key: "page", value: "commit" },
116
+ { key: "repo", value: repo },
117
+ { key: "hash", value: commit.hash },
118
+ ],
119
+ },
120
+ }));
121
+ return page.next === undefined
122
+ ? { ...identity, state: "full", rows }
123
+ : { ...identity, state: "full", rows, next: page.next };
124
+ },
125
+ });
package/backend/git.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Bonsai's view of git: the host's GIT binding (agar's `GitBinding`, the
3
+ * Artifacts binding surface scoped to this installation) wrapped as a
4
+ * GitStore whose results are parsed into the shared contract. Bonsai never
5
+ * holds a credential or a provider remote.
6
+ */
7
+ import * as z from "zod";
8
+ import { countingGit } from "@fungi.computer/agar/read-copy";
9
+ import { ref as refSchema, type Ref } from "../shared/contract.js";
10
+ import {
11
+ commit as commitSchema,
12
+ treeEntry as treeEntrySchema,
13
+ type Commit,
14
+ type TreeEntry,
15
+ } from "@fungi.computer/agar/roles/files";
16
+
17
+ import type {
18
+ GitBinding,
19
+ GitExchangeRequest,
20
+ GitExchangeResponse,
21
+ GitRepositoryInfo as RepoInfo,
22
+ } from "@fungi.computer/agar/git";
23
+
24
+ export type { GitBinding, GitExchangeRequest, GitExchangeResponse, RepoInfo };
25
+
26
+ export type GitStore = Readonly<{
27
+ binding: GitBinding;
28
+ cloneUrl(name: string): Promise<string>;
29
+ create(name: string, description: string): Promise<RepoInfo>;
30
+ fork(source: string, target: string, description: string): Promise<RepoInfo>;
31
+ info(name: string): Promise<RepoInfo | null>;
32
+ log(
33
+ name: string,
34
+ ref: string,
35
+ limit: number,
36
+ offset?: number,
37
+ ): Promise<readonly Commit[]>;
38
+ commit(name: string, hash: string): Promise<Commit | null>;
39
+ tree(name: string, hash: string): Promise<readonly TreeEntry[] | null>;
40
+ blob(name: string, hash: string): Promise<Uint8Array | null>;
41
+ refs(name: string): Promise<readonly Ref[]>;
42
+ exchange(
43
+ name: string,
44
+ request: GitExchangeRequest,
45
+ ): Promise<GitExchangeResponse>;
46
+ }>;
47
+
48
+ const repoInfoSchema = z.object({
49
+ name: z.string(),
50
+ defaultBranch: z.string(),
51
+ lastPushAt: z.string().nullable(),
52
+ source: z.string().nullable(),
53
+ });
54
+
55
+ const exchangeSchema = z.object({
56
+ status: z.number(),
57
+ headers: z.record(z.string(), z.string()),
58
+ body: z.instanceof(Uint8Array),
59
+ });
60
+
61
+ /** Parse every binding result: the binding is an I/O boundary. */
62
+ export function gitStore(binding: GitBinding): GitStore {
63
+ return {
64
+ binding,
65
+ async cloneUrl(name) {
66
+ return z.url().parse(await binding.cloneUrl(name));
67
+ },
68
+ async create(name, description) {
69
+ return repoInfoSchema.parse(
70
+ await binding.create(name, { description, defaultBranch: "main" }),
71
+ );
72
+ },
73
+ async fork(source, target, description) {
74
+ return repoInfoSchema.parse(
75
+ await binding.fork(source, target, { description }),
76
+ );
77
+ },
78
+ async info(name) {
79
+ const raw = await binding.info(name);
80
+ return raw === null ? null : repoInfoSchema.parse(raw);
81
+ },
82
+ async log(name, ref, limit, offset = 0) {
83
+ return z
84
+ .array(commitSchema)
85
+ .parse(await binding.log(name, { ref, limit, offset }));
86
+ },
87
+ async commit(name, hash) {
88
+ const raw = await binding.readCommit(name, hash);
89
+ return raw === null ? null : commitSchema.parse(raw);
90
+ },
91
+ async tree(name, hash) {
92
+ const raw = await binding.readTree(name, hash);
93
+ return raw === null ? null : z.array(treeEntrySchema).parse(raw);
94
+ },
95
+ async blob(name, hash) {
96
+ const raw = await binding.readBlob(name, hash);
97
+ return raw === null ? null : z.instanceof(Uint8Array).parse(raw);
98
+ },
99
+ async refs(name) {
100
+ return z.array(refSchema).parse(await binding.refs(name));
101
+ },
102
+ async exchange(name, request) {
103
+ return exchangeSchema.parse(await binding.exchange(name, request));
104
+ },
105
+ };
106
+ }
107
+
108
+ /** One provider budget and lifetime for an App-owned projection frame. */
109
+ export async function readCopyFrame<T>(
110
+ store: GitStore,
111
+ body: (store: GitStore, signal: AbortSignal) => Promise<T>,
112
+ ): Promise<T> {
113
+ const lifetime = new AbortController();
114
+ try {
115
+ const counted = countingGit(store.binding, {
116
+ limit: 40,
117
+ signal: lifetime.signal,
118
+ });
119
+ return await body(gitStore(counted.git), lifetime.signal);
120
+ } finally {
121
+ lifetime.abort();
122
+ }
123
+ }