@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,228 @@
1
+ /**
2
+ * Tree and line diffs computed from git objects. Identical subtree hashes are
3
+ * skipped, so a diff only reads the objects that changed.
4
+ */
5
+ import { diffLines } from "diff";
6
+ import type { Diff, FileDiff } from "../shared/contract.js";
7
+ import type { TreeEntry } from "@fungi.computer/agar/roles/files";
8
+ import type { GitStore } from "./git.js";
9
+
10
+ const MAX_FILES = 200;
11
+ const MAX_DIFF_BYTES = 200_000;
12
+ /** Encoded text one whole diff may carry: a diff is stored as one read-copy
13
+ * row (512 KB), so the files' texts share this bound with their metadata. */
14
+ const MAX_DIFF_TEXT_BYTES = 384_000;
15
+ const encoder = new TextEncoder();
16
+ const BLOB_READS_AT_ONCE = 8;
17
+ const decoder = new TextDecoder("utf-8", { fatal: true });
18
+
19
+ export type PathChange = Readonly<{
20
+ path: string;
21
+ before: string | null;
22
+ after: string | null;
23
+ }>;
24
+
25
+ export function isBinary(bytes: Uint8Array): boolean {
26
+ const sample = bytes.subarray(0, 8000);
27
+ if (sample.includes(0)) return true;
28
+ try {
29
+ decoder.decode(sample.length === bytes.length ? bytes : sample);
30
+ return false;
31
+ } catch {
32
+ return sample.length === bytes.length;
33
+ }
34
+ }
35
+
36
+ export function decodeText(
37
+ bytes: Uint8Array,
38
+ ):
39
+ | Readonly<{ kind: "text"; text: string }>
40
+ | Readonly<{ kind: "invalid-utf8" }> {
41
+ try {
42
+ return { kind: "text", text: decoder.decode(bytes) };
43
+ } catch (error) {
44
+ if (error instanceof TypeError) return { kind: "invalid-utf8" };
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Paths whose blob changed between two trees (null tree = empty).
51
+ *
52
+ * Subtrees are read one at a time: a read-copy frame stops at its provider
53
+ * budget, and each completed read is kept by the projection store, so a diff
54
+ * too large for one frame advances across foreground pages instead of
55
+ * launching every read at once and keeping none of them.
56
+ */
57
+ export async function changedPaths(
58
+ store: GitStore,
59
+ repo: string,
60
+ before: string | null,
61
+ after: string | null,
62
+ prefix = "",
63
+ ): Promise<PathChange[]> {
64
+ if (before === after) return [];
65
+ const [left, right] = await Promise.all([
66
+ before === null ? [] : store.tree(repo, before),
67
+ after === null ? [] : store.tree(repo, after),
68
+ ]);
69
+ const index = (entries: readonly TreeEntry[] | null) =>
70
+ new Map((entries ?? []).map((entry) => [entry.name, entry]));
71
+ const oldEntries = index(left);
72
+ const newEntries = index(right);
73
+ const names = [
74
+ ...new Set([...oldEntries.keys(), ...newEntries.keys()]),
75
+ ].sort();
76
+ const nested: {
77
+ before: string | null;
78
+ after: string | null;
79
+ prefix: string;
80
+ }[] = [];
81
+ const changes: PathChange[] = [];
82
+ for (const name of names) {
83
+ const a = oldEntries.get(name);
84
+ const b = newEntries.get(name);
85
+ if (a?.hash === b?.hash && a?.type === b?.type) continue;
86
+ const path = prefix + name;
87
+ const aTree = a?.type === "tree" ? a.hash : null;
88
+ const bTree = b?.type === "tree" ? b.hash : null;
89
+ if (aTree !== null || bTree !== null)
90
+ nested.push({ before: aTree, after: bTree, prefix: `${path}/` });
91
+ const aBlob = a?.type === "blob" ? a.hash : null;
92
+ const bBlob = b?.type === "blob" ? b.hash : null;
93
+ if (aBlob !== bBlob) changes.push({ path, before: aBlob, after: bBlob });
94
+ }
95
+ for (const subtree of nested)
96
+ changes.push(
97
+ ...(await changedPaths(
98
+ store,
99
+ repo,
100
+ subtree.before,
101
+ subtree.after,
102
+ subtree.prefix,
103
+ )),
104
+ );
105
+ return changes.sort((x, y) => x.path.localeCompare(y.path));
106
+ }
107
+
108
+ async function fileDiff(
109
+ store: GitStore,
110
+ repo: string,
111
+ change: PathChange,
112
+ ): Promise<FileDiff> {
113
+ const [oldBytes, newBytes] = await Promise.all([
114
+ change.before === null ? new Uint8Array() : store.blob(repo, change.before),
115
+ change.after === null ? new Uint8Array() : store.blob(repo, change.after),
116
+ ]);
117
+ const status =
118
+ change.before === null
119
+ ? "added"
120
+ : change.after === null
121
+ ? "removed"
122
+ : "modified";
123
+ const base = {
124
+ path: change.path,
125
+ oldPath: null,
126
+ status,
127
+ additions: 0,
128
+ deletions: 0,
129
+ binary: false,
130
+ truncated: false,
131
+ oldText: null,
132
+ newText: null,
133
+ } satisfies FileDiff;
134
+ if (oldBytes === null || newBytes === null)
135
+ return { ...base, truncated: true };
136
+ if (isBinary(oldBytes) || isBinary(newBytes))
137
+ return { ...base, binary: true };
138
+ if (oldBytes.length + newBytes.length > MAX_DIFF_BYTES)
139
+ return { ...base, truncated: true };
140
+ const before = decodeText(oldBytes);
141
+ const after = decodeText(newBytes);
142
+ if (before.kind === "invalid-utf8" || after.kind === "invalid-utf8")
143
+ return { ...base, binary: true };
144
+ const oldText = before.text;
145
+ const newText = after.text;
146
+ let additions = 0;
147
+ let deletions = 0;
148
+ for (const part of diffLines(oldText, newText)) {
149
+ if (part.added) additions += part.count;
150
+ else if (part.removed) deletions += part.count;
151
+ }
152
+ return { ...base, additions, deletions, oldText, newText };
153
+ }
154
+
155
+ /** A full diff between two trees in one repository. */
156
+ export async function treeDiff(
157
+ store: GitStore,
158
+ repo: string,
159
+ before: string | null,
160
+ after: string | null,
161
+ ): Promise<Diff> {
162
+ const changes = await changedPaths(store, repo, before, after);
163
+ const listed = changes.slice(0, MAX_FILES);
164
+ // A few blob reads at a time, so a frame's completed reads are kept.
165
+ const computed: FileDiff[] = [];
166
+ for (let start = 0; start < listed.length; start += BLOB_READS_AT_ONCE)
167
+ computed.push(
168
+ ...(await Promise.all(
169
+ listed
170
+ .slice(start, start + BLOB_READS_AT_ONCE)
171
+ .map((change) => fileDiff(store, repo, change)),
172
+ )),
173
+ );
174
+ // Texts are kept in path order while they fit; later files stay listed,
175
+ // with their line counts, as too large to show.
176
+ let remaining = MAX_DIFF_TEXT_BYTES;
177
+ const files = computed.map((file) => {
178
+ if (file.oldText === null && file.newText === null) return file;
179
+ const bytes = encoder.encode(
180
+ JSON.stringify([file.oldText, file.newText]),
181
+ ).byteLength;
182
+ if (bytes > remaining)
183
+ return { ...file, truncated: true, oldText: null, newText: null };
184
+ remaining -= bytes;
185
+ return file;
186
+ });
187
+ return {
188
+ files,
189
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
190
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
191
+ omittedFiles: changes.length - listed.length,
192
+ };
193
+ }
194
+
195
+ const MAX_PATHS = 3000;
196
+
197
+ /** Every path under a tree, directories with a trailing slash, bounded. */
198
+ export async function listPaths(
199
+ store: GitStore,
200
+ repo: string,
201
+ tree: string,
202
+ ): Promise<{ paths: string[]; truncated: boolean }> {
203
+ const paths: string[] = [];
204
+ let level: { hash: string; prefix: string }[] = [{ hash: tree, prefix: "" }];
205
+ while (level.length > 0 && paths.length < MAX_PATHS) {
206
+ const listings = await Promise.all(
207
+ level.map(async (item) => ({
208
+ prefix: item.prefix,
209
+ entries: (await store.tree(repo, item.hash)) ?? [],
210
+ })),
211
+ );
212
+ const next: { hash: string; prefix: string }[] = [];
213
+ for (const listing of listings)
214
+ for (const entry of listing.entries) {
215
+ if (entry.type === "commit") continue;
216
+ const path = `${listing.prefix}${entry.name}`;
217
+ if (entry.type === "tree") {
218
+ paths.push(`${path}/`);
219
+ next.push({ hash: entry.hash, prefix: `${path}/` });
220
+ } else paths.push(path);
221
+ }
222
+ level = next;
223
+ }
224
+ return {
225
+ paths: paths.slice(0, MAX_PATHS),
226
+ truncated: paths.length >= MAX_PATHS,
227
+ };
228
+ }