@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +835 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +5 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +715 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { Button } from "@fungi.computer/caps/components/button";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from "@fungi.computer/caps/components/card";
|
|
10
|
+
import { Input } from "@fungi.computer/caps/components/input";
|
|
11
|
+
import { repositoryDeletion } from "../shared/repository-deletion.js";
|
|
12
|
+
import { callMethod } from "./transport.js";
|
|
13
|
+
import { href, Link, useRouter } from "./router.js";
|
|
14
|
+
import { AppBuildPanel } from "./app-build-panel.js";
|
|
15
|
+
|
|
16
|
+
/** Keep cleanup retry usable after the public repository entry is withdrawn. */
|
|
17
|
+
export function RepoSettings({ repo }: { repo: string }) {
|
|
18
|
+
const { go } = useRouter();
|
|
19
|
+
const client = useQueryClient();
|
|
20
|
+
const [confirming, setConfirming] = useState(false);
|
|
21
|
+
const [confirmation, setConfirmation] = useState("");
|
|
22
|
+
const deletion = useMutation({
|
|
23
|
+
mutationFn: () => callMethod("repos.delete", { repo }, repositoryDeletion),
|
|
24
|
+
onSuccess: async () => {
|
|
25
|
+
client.removeQueries({ queryKey: ["repo", repo] });
|
|
26
|
+
await client.invalidateQueries({ queryKey: ["repos"] });
|
|
27
|
+
go("/");
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const failure = deletion.error?.message;
|
|
31
|
+
const retryMessage =
|
|
32
|
+
failure === "Provider temporarily unavailable; retry deletion" ||
|
|
33
|
+
failure === "Bonsai could not finish that request"
|
|
34
|
+
? "Deletion didn't finish because storage was unavailable. Confirm again to finish removing it."
|
|
35
|
+
: failure;
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex min-w-0 flex-col gap-5">
|
|
38
|
+
<nav
|
|
39
|
+
aria-label="Repository"
|
|
40
|
+
className="flex flex-wrap items-center gap-2 text-sm"
|
|
41
|
+
>
|
|
42
|
+
<Link to="/">Bonsai</Link>
|
|
43
|
+
<span aria-hidden="true">/</span>
|
|
44
|
+
<Link
|
|
45
|
+
className="break-all"
|
|
46
|
+
to={href({
|
|
47
|
+
page: "repo",
|
|
48
|
+
repo,
|
|
49
|
+
tab: "overview",
|
|
50
|
+
ref: null,
|
|
51
|
+
path: "",
|
|
52
|
+
file: null,
|
|
53
|
+
})}
|
|
54
|
+
>
|
|
55
|
+
{repo}
|
|
56
|
+
</Link>
|
|
57
|
+
<span aria-hidden="true">/</span>
|
|
58
|
+
<span aria-current="page">Settings</span>
|
|
59
|
+
</nav>
|
|
60
|
+
<h1 className="caps-card-title">Repository settings</h1>
|
|
61
|
+
<Card className="w-full max-w-2xl" variant="outline">
|
|
62
|
+
<CardHeader>
|
|
63
|
+
<CardTitle level={2}>Delete repository</CardTitle>
|
|
64
|
+
</CardHeader>
|
|
65
|
+
<CardContent className="flex flex-col gap-4">
|
|
66
|
+
<p>
|
|
67
|
+
This removes <strong className="break-all">{repo}</strong> and its
|
|
68
|
+
Git content. This cannot be undone.
|
|
69
|
+
</p>
|
|
70
|
+
<p className="text-sm text-[var(--stipe-fg-muted)]">
|
|
71
|
+
Only the owner can delete a repository. Delete its forks first.
|
|
72
|
+
</p>
|
|
73
|
+
{confirming ? (
|
|
74
|
+
<form
|
|
75
|
+
className="flex flex-col gap-4"
|
|
76
|
+
onSubmit={(event) => {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
if (confirmation === repo && !deletion.isPending)
|
|
79
|
+
deletion.mutate();
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<label
|
|
83
|
+
className="flex flex-col gap-2"
|
|
84
|
+
htmlFor="repository-deletion-name"
|
|
85
|
+
>
|
|
86
|
+
<span>Repository name</span>
|
|
87
|
+
<span className="text-sm text-[var(--stipe-fg-muted)]">
|
|
88
|
+
Type the name above to confirm.
|
|
89
|
+
</span>
|
|
90
|
+
<Input
|
|
91
|
+
id="repository-deletion-name"
|
|
92
|
+
value={confirmation}
|
|
93
|
+
onChange={(event) => {
|
|
94
|
+
setConfirmation(event.target.value);
|
|
95
|
+
}}
|
|
96
|
+
autoComplete="off"
|
|
97
|
+
disabled={deletion.isPending}
|
|
98
|
+
autoFocus
|
|
99
|
+
/>
|
|
100
|
+
</label>
|
|
101
|
+
{deletion.isError ? (
|
|
102
|
+
<div role="alert" className="text-sm text-error">
|
|
103
|
+
<p>{retryMessage}</p>
|
|
104
|
+
</div>
|
|
105
|
+
) : null}
|
|
106
|
+
<div className="flex flex-wrap gap-3">
|
|
107
|
+
<Button
|
|
108
|
+
variant="destructive"
|
|
109
|
+
type="submit"
|
|
110
|
+
disabled={confirmation !== repo || deletion.isPending}
|
|
111
|
+
>
|
|
112
|
+
{deletion.isPending ? "Deleting…" : "Confirm deletion"}
|
|
113
|
+
</Button>
|
|
114
|
+
<Button
|
|
115
|
+
variant="outline"
|
|
116
|
+
type="button"
|
|
117
|
+
disabled={deletion.isPending}
|
|
118
|
+
onClick={() => {
|
|
119
|
+
setConfirming(false);
|
|
120
|
+
setConfirmation("");
|
|
121
|
+
deletion.reset();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
Cancel
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
</form>
|
|
128
|
+
) : (
|
|
129
|
+
<div>
|
|
130
|
+
<Button
|
|
131
|
+
variant="destructive"
|
|
132
|
+
onClick={() => {
|
|
133
|
+
setConfirming(true);
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
Delete repository
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</CardContent>
|
|
141
|
+
</Card>
|
|
142
|
+
<AppBuildPanel />
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
package/web/router.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App navigation stays in memory. Numbered Changes also accept a hash link
|
|
3
|
+
* when the frame opens, or when its hash changes.
|
|
4
|
+
*/
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useState,
|
|
11
|
+
type AnchorHTMLAttributes,
|
|
12
|
+
type MouseEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react";
|
|
15
|
+
|
|
16
|
+
export type Route =
|
|
17
|
+
| { page: "home" }
|
|
18
|
+
| {
|
|
19
|
+
page: "repo";
|
|
20
|
+
repo: string;
|
|
21
|
+
tab: "code" | "commits" | "changes" | "issues" | "overview" | "settings";
|
|
22
|
+
ref: string | null;
|
|
23
|
+
path: string;
|
|
24
|
+
file: string | null;
|
|
25
|
+
commit?: string;
|
|
26
|
+
}
|
|
27
|
+
| { page: "commit"; repo: string; hash: string }
|
|
28
|
+
| { page: "change"; repo: string; number: number };
|
|
29
|
+
|
|
30
|
+
function parseRoute(path: string): Route {
|
|
31
|
+
const location = new URL(path, "https://bonsai.local");
|
|
32
|
+
if (location.hash.startsWith("#change/")) {
|
|
33
|
+
const [kind, repo, rawNumber] = location.hash.slice(1).split("/");
|
|
34
|
+
const number = z.coerce.number().int().positive().safeParse(rawNumber);
|
|
35
|
+
if (kind === "change" && repo && number.success)
|
|
36
|
+
return {
|
|
37
|
+
page: "change",
|
|
38
|
+
repo: decodeURIComponent(repo),
|
|
39
|
+
number: number.data,
|
|
40
|
+
};
|
|
41
|
+
return { page: "home" };
|
|
42
|
+
}
|
|
43
|
+
const parts = location.pathname
|
|
44
|
+
.split("/")
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map(decodeURIComponent);
|
|
47
|
+
const [repo, section, hash] = parts;
|
|
48
|
+
if (repo === undefined) return { page: "home" };
|
|
49
|
+
if (section === "commit" && hash !== undefined)
|
|
50
|
+
return { page: "commit", repo, hash };
|
|
51
|
+
const tab = location.searchParams.get("tab");
|
|
52
|
+
const route: Extract<Route, { page: "repo" }> = {
|
|
53
|
+
page: "repo",
|
|
54
|
+
repo,
|
|
55
|
+
tab:
|
|
56
|
+
tab === "commits" ||
|
|
57
|
+
tab === "changes" ||
|
|
58
|
+
tab === "issues" ||
|
|
59
|
+
tab === "settings" ||
|
|
60
|
+
tab === "overview"
|
|
61
|
+
? tab
|
|
62
|
+
: tab === "insights"
|
|
63
|
+
? "overview"
|
|
64
|
+
: tab === "code"
|
|
65
|
+
? "code"
|
|
66
|
+
: "overview",
|
|
67
|
+
ref: location.searchParams.get("ref"),
|
|
68
|
+
path: location.searchParams.get("path") ?? "",
|
|
69
|
+
file: location.searchParams.get("file"),
|
|
70
|
+
};
|
|
71
|
+
const commit = location.searchParams.get("commit");
|
|
72
|
+
if (commit !== null) route.commit = commit;
|
|
73
|
+
return route;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function href(route: Route): string {
|
|
77
|
+
if (route.page === "home") return "/";
|
|
78
|
+
if (route.page === "commit")
|
|
79
|
+
return `/${encodeURIComponent(route.repo)}/commit/${route.hash}`;
|
|
80
|
+
if (route.page === "change")
|
|
81
|
+
return `#change/${encodeURIComponent(route.repo)}/${String(route.number)}`;
|
|
82
|
+
const params = new URLSearchParams();
|
|
83
|
+
if (route.tab !== "overview") params.set("tab", route.tab);
|
|
84
|
+
if (route.ref !== null) params.set("ref", route.ref);
|
|
85
|
+
if (route.path !== "") params.set("path", route.path);
|
|
86
|
+
if (route.file !== null) params.set("file", route.file);
|
|
87
|
+
if (route.commit !== undefined) params.set("commit", route.commit);
|
|
88
|
+
const query = params.toString();
|
|
89
|
+
return `/${encodeURIComponent(route.repo)}${query ? `?${query}` : ""}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type RouterValue = Readonly<{ route: Route; go: (to: string) => void }>;
|
|
93
|
+
|
|
94
|
+
const RouterContext = createContext<RouterValue | null>(null);
|
|
95
|
+
|
|
96
|
+
export function RouterProvider({ children }: { children: ReactNode }) {
|
|
97
|
+
const [path, setPath] = useState(() =>
|
|
98
|
+
location.hash.startsWith("#change/") ? location.hash : "/",
|
|
99
|
+
);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const changed = () => {
|
|
102
|
+
setPath(location.hash.startsWith("#change/") ? location.hash : "/");
|
|
103
|
+
};
|
|
104
|
+
window.addEventListener("hashchange", changed);
|
|
105
|
+
return () => {
|
|
106
|
+
window.removeEventListener("hashchange", changed);
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
const go = (to: string) => {
|
|
110
|
+
setPath(to);
|
|
111
|
+
scrollTo({ top: 0 });
|
|
112
|
+
};
|
|
113
|
+
return (
|
|
114
|
+
<RouterContext.Provider value={{ route: parseRoute(path), go }}>
|
|
115
|
+
{children}
|
|
116
|
+
</RouterContext.Provider>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useRouter(): RouterValue {
|
|
121
|
+
const value = useContext(RouterContext);
|
|
122
|
+
if (value === null) throw new Error("Bonsai routes need a RouterProvider");
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function Link({
|
|
127
|
+
to,
|
|
128
|
+
onClick,
|
|
129
|
+
...props
|
|
130
|
+
}: { to: string } & AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
131
|
+
const { go } = useRouter();
|
|
132
|
+
return (
|
|
133
|
+
<a
|
|
134
|
+
{...props}
|
|
135
|
+
href={to}
|
|
136
|
+
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
137
|
+
onClick?.(event);
|
|
138
|
+
if (
|
|
139
|
+
event.defaultPrevented ||
|
|
140
|
+
event.metaKey ||
|
|
141
|
+
event.ctrlKey ||
|
|
142
|
+
event.shiftKey ||
|
|
143
|
+
event.button !== 0
|
|
144
|
+
)
|
|
145
|
+
return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
go(to);
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
package/web/transport.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the UI reaches Bonsai's App methods: agar's `app.methods` capability
|
|
3
|
+
* when the host offers it, otherwise the admitted App backend. The host attributes every call
|
|
4
|
+
* to the signed-in person.
|
|
5
|
+
*/
|
|
6
|
+
import { appsOpen, type AppRoleChoice } from "@fungi.computer/agar/apps";
|
|
7
|
+
import { AppError, type AppRoot } from "@fungi.computer/agar";
|
|
8
|
+
import { appBackend, appMethods } from "@fungi.computer/agar/backend";
|
|
9
|
+
import type * as z from "zod";
|
|
10
|
+
import { errorBody, jsonValue, type JsonValue } from "../shared/contract.js";
|
|
11
|
+
import {
|
|
12
|
+
readCopyRebuilding,
|
|
13
|
+
copyRebuildPage,
|
|
14
|
+
filesNavigateIntent,
|
|
15
|
+
filesRole,
|
|
16
|
+
} from "@fungi.computer/agar/roles/files";
|
|
17
|
+
|
|
18
|
+
export class ApiError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly status: number,
|
|
21
|
+
message: string,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let connection: Pick<AppRoot, "capabilities"> | null = null;
|
|
28
|
+
|
|
29
|
+
/** Remember the host connection mountApp handed over (null outside a host). */
|
|
30
|
+
export function connect(app: Pick<AppRoot, "capabilities"> | null): void {
|
|
31
|
+
connection = app;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The Team's files-role App as the shell's role routing names it; null hides the entry. */
|
|
35
|
+
export async function filesEditor(): Promise<AppRoleChoice> {
|
|
36
|
+
if (!connection?.capabilities.has(appsOpen)) return null;
|
|
37
|
+
return connection.capabilities.require(appsOpen).role(filesRole);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Open the file in whichever App the Team chose for the files role. */
|
|
41
|
+
export async function editInFilesEditor(
|
|
42
|
+
repository: string,
|
|
43
|
+
branch: string,
|
|
44
|
+
path: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (connection === null)
|
|
47
|
+
throw new ApiError(503, "Open Bonsai from an App host");
|
|
48
|
+
await connection.capabilities.require(appsOpen).open({
|
|
49
|
+
role: filesRole,
|
|
50
|
+
intent: filesNavigateIntent({ repository, branch, path }),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Call one App method and parse its result against the shared contract. */
|
|
55
|
+
export async function callMethod<S extends z.ZodType>(
|
|
56
|
+
method: string,
|
|
57
|
+
input: JsonValue,
|
|
58
|
+
schema: S,
|
|
59
|
+
): Promise<z.infer<S>> {
|
|
60
|
+
if (connection === null)
|
|
61
|
+
throw new ApiError(503, "Open Bonsai from an App host such as Oyster");
|
|
62
|
+
// Typed method calls let the host apply each method's effect (public
|
|
63
|
+
// visitors may call read methods); hosts without them get the backend.
|
|
64
|
+
const capabilities = connection.capabilities;
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = capabilities.has(appMethods)
|
|
68
|
+
? await capabilities
|
|
69
|
+
.require(appMethods)
|
|
70
|
+
.call(method, JSON.stringify(input))
|
|
71
|
+
: await capabilities
|
|
72
|
+
.require(appBackend)
|
|
73
|
+
.request(JSON.stringify({ method, input }));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof AppError && error.code === "CAPABILITY_DENIED")
|
|
76
|
+
throw new ApiError(403, "Sign in to this Oyster to do that");
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (readCopyRebuilding.safeParse(result).success) {
|
|
80
|
+
let cursor: string | null = null;
|
|
81
|
+
let page;
|
|
82
|
+
do {
|
|
83
|
+
page = await callMethod("rebuildCopies", { cursor }, copyRebuildPage);
|
|
84
|
+
cursor = page.cursor;
|
|
85
|
+
} while (!page.done);
|
|
86
|
+
return callMethod(method, input, schema);
|
|
87
|
+
}
|
|
88
|
+
const failure = errorBody.safeParse(result);
|
|
89
|
+
if (failure.success) throw new ApiError(400, failure.data.error);
|
|
90
|
+
return schema.parse(jsonValue.parse(result));
|
|
91
|
+
}
|
package/web/views.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bonsai's file, diff, tree and Markdown views. Code and diffs are Pierre
|
|
3
|
+
* (themed like Inkcap: Catppuccin latte/mocha by the page theme), the tree is
|
|
4
|
+
* Pierre trees through Stipe's projection, and Markdown is TanStack Markdown
|
|
5
|
+
* inside Caps Prose.
|
|
6
|
+
*/
|
|
7
|
+
import { Markdown as CapsMarkdown } from "@fungi.computer/caps/components/markdown";
|
|
8
|
+
import { pierreTreesStyle } from "@fungi.computer/stipe/pierre/trees";
|
|
9
|
+
import { getTheme, subscribeTheme } from "@fungi.computer/stipe/theme";
|
|
10
|
+
import { File as PierreFile, MultiFileDiff } from "@pierre/diffs/react";
|
|
11
|
+
import { FileTree, useFileTree } from "@pierre/trees/react";
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
+
import type { FileDiff } from "../shared/contract.js";
|
|
14
|
+
|
|
15
|
+
const DARK_THEMES = new Set([
|
|
16
|
+
"mocha",
|
|
17
|
+
"dark",
|
|
18
|
+
"readout-night",
|
|
19
|
+
"field-guide-night",
|
|
20
|
+
"embed-dark",
|
|
21
|
+
"phosphor",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
25
|
+
|
|
26
|
+
function readThemeType(): "light" | "dark" {
|
|
27
|
+
const theme = getTheme();
|
|
28
|
+
if (theme !== null) return DARK_THEMES.has(theme) ? "dark" : "light";
|
|
29
|
+
return matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Light or dark, following the page's Stipe theme or the reader's setting. */
|
|
33
|
+
function useThemeType(): "light" | "dark" {
|
|
34
|
+
const [type, setType] = useState(readThemeType);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const query = matchMedia(DARK_QUERY);
|
|
37
|
+
const update = () => {
|
|
38
|
+
setType(readThemeType());
|
|
39
|
+
};
|
|
40
|
+
query.addEventListener("change", update);
|
|
41
|
+
const unsubscribe = subscribeTheme(update);
|
|
42
|
+
return () => {
|
|
43
|
+
query.removeEventListener("change", update);
|
|
44
|
+
unsubscribe();
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
return type;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pierreTheme = {
|
|
51
|
+
dark: "catppuccin-mocha",
|
|
52
|
+
light: "catppuccin-latte",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export function FileView({ path, text }: { path: string; text: string }) {
|
|
56
|
+
const themeType = useThemeType();
|
|
57
|
+
const file = useMemo(() => ({ name: path, contents: text }), [path, text]);
|
|
58
|
+
return (
|
|
59
|
+
<div className="bonsai-pierre">
|
|
60
|
+
<PierreFile
|
|
61
|
+
file={file}
|
|
62
|
+
options={{
|
|
63
|
+
disableFileHeader: true,
|
|
64
|
+
overflow: "scroll",
|
|
65
|
+
themeType,
|
|
66
|
+
theme: pierreTheme,
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function DiffView({ file }: { file: FileDiff }) {
|
|
74
|
+
const themeType = useThemeType();
|
|
75
|
+
const oldFile = useMemo(
|
|
76
|
+
() => ({ name: file.path, contents: file.oldText ?? "" }),
|
|
77
|
+
[file.path, file.oldText],
|
|
78
|
+
);
|
|
79
|
+
const newFile = useMemo(
|
|
80
|
+
() => ({ name: file.path, contents: file.newText ?? "" }),
|
|
81
|
+
[file.path, file.newText],
|
|
82
|
+
);
|
|
83
|
+
if (file.oldText === null || file.newText === null)
|
|
84
|
+
return (
|
|
85
|
+
<section
|
|
86
|
+
className="caps-card caps-card-surface bonsai-diff-file"
|
|
87
|
+
aria-label={file.path}
|
|
88
|
+
>
|
|
89
|
+
<header>
|
|
90
|
+
<span className="bonsai-diff-path">{file.path}</span>
|
|
91
|
+
</header>
|
|
92
|
+
<p className="bonsai-diff-note">
|
|
93
|
+
{file.binary ? "Binary file" : "Too large to show"}
|
|
94
|
+
</p>
|
|
95
|
+
</section>
|
|
96
|
+
);
|
|
97
|
+
return (
|
|
98
|
+
<section
|
|
99
|
+
className="caps-card caps-card-surface bonsai-diff-file bonsai-pierre"
|
|
100
|
+
aria-label={file.path}
|
|
101
|
+
id={`file-${file.path}`}
|
|
102
|
+
>
|
|
103
|
+
<MultiFileDiff
|
|
104
|
+
oldFile={oldFile}
|
|
105
|
+
newFile={newFile}
|
|
106
|
+
options={{
|
|
107
|
+
diffStyle: "unified",
|
|
108
|
+
overflow: "scroll",
|
|
109
|
+
themeType,
|
|
110
|
+
theme: pierreTheme,
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type TreeProps = Readonly<{
|
|
118
|
+
paths: readonly string[];
|
|
119
|
+
active: string | null;
|
|
120
|
+
onOpen: (path: string) => void;
|
|
121
|
+
}>;
|
|
122
|
+
|
|
123
|
+
/** The repository tree. Remount it (key) when the path set changes. */
|
|
124
|
+
export function RepoTree({ paths, active, onOpen }: TreeProps) {
|
|
125
|
+
const open = useRef(onOpen);
|
|
126
|
+
open.current = onOpen;
|
|
127
|
+
const { model } = useFileTree({
|
|
128
|
+
paths,
|
|
129
|
+
itemHeight: 30,
|
|
130
|
+
initialExpansion: 1,
|
|
131
|
+
onSelectionChange(selected) {
|
|
132
|
+
const path = selected[0];
|
|
133
|
+
if (path !== undefined && !path.endsWith("/")) open.current(path);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (active !== null) model.focusPath(active);
|
|
138
|
+
}, [model, active]);
|
|
139
|
+
return (
|
|
140
|
+
<FileTree
|
|
141
|
+
model={model}
|
|
142
|
+
className="bonsai-tree"
|
|
143
|
+
style={pierreTreesStyle({ itemHeight: 30 })}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Markdown from a repository, rendered by the shared Caps component. */
|
|
149
|
+
export function Markdown({ text }: { text: string }) {
|
|
150
|
+
return <CapsMarkdown className="bonsai-markdown">{text}</CapsMarkdown>;
|
|
151
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Activity } from "../shared/contract.js";
|
|
2
|
+
import { AgentMark, AgentName, type AgentColors } from "./parts.js";
|
|
3
|
+
import { relativeTime } from "./format.js";
|
|
4
|
+
|
|
5
|
+
const activityWindowMs = 15 * 60_000;
|
|
6
|
+
|
|
7
|
+
function refLabel(ref: string): string {
|
|
8
|
+
return ref.replace(/^refs\/heads\//u, "").replace(/^refs\/tags\//u, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Agents who pushed in this repository family in the last 15 minutes. */
|
|
12
|
+
export function workingNow(events: readonly Activity[]) {
|
|
13
|
+
const since = Date.now() - activityWindowMs;
|
|
14
|
+
const byAgent = new Map<
|
|
15
|
+
string,
|
|
16
|
+
{ repo: string; ref: string; at: number; paths: Set<string> }
|
|
17
|
+
>();
|
|
18
|
+
for (const event of events) {
|
|
19
|
+
if (event.type !== "push" || event.at < since) continue;
|
|
20
|
+
const current = byAgent.get(event.actor);
|
|
21
|
+
const ref = refLabel(event.data.updates[0]?.ref ?? "");
|
|
22
|
+
if (!current)
|
|
23
|
+
byAgent.set(event.actor, {
|
|
24
|
+
repo: event.repo,
|
|
25
|
+
ref,
|
|
26
|
+
at: event.at,
|
|
27
|
+
paths: new Set(event.data.paths),
|
|
28
|
+
});
|
|
29
|
+
else for (const path of event.data.paths) current.paths.add(path);
|
|
30
|
+
}
|
|
31
|
+
return [...byAgent.entries()].map(([agent, value]) => ({ agent, ...value }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Expire recorded activity even when the cached event page stays unchanged. */
|
|
35
|
+
export function useWorkingNow(events: readonly Activity[]) {
|
|
36
|
+
const [, refresh] = useReducer((value: number) => value + 1, 0);
|
|
37
|
+
const current = workingNow(events);
|
|
38
|
+
const expiresAt = current.length
|
|
39
|
+
? Math.min(...current.map((item) => item.at)) + activityWindowMs + 1
|
|
40
|
+
: null;
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (expiresAt === null) return;
|
|
43
|
+
const timeout = setTimeout(refresh, Math.max(0, expiresAt - Date.now()));
|
|
44
|
+
return () => {
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
};
|
|
47
|
+
}, [expiresAt]);
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function areaOf(path: string): string {
|
|
52
|
+
const parts = path.split("/");
|
|
53
|
+
return parts.length > 1 ? `${parts.slice(0, -1).join("/")}/` : path;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function WorkingNow({
|
|
57
|
+
events,
|
|
58
|
+
colors,
|
|
59
|
+
}: {
|
|
60
|
+
events: readonly Activity[];
|
|
61
|
+
colors: AgentColors;
|
|
62
|
+
}) {
|
|
63
|
+
const working = useWorkingNow(events);
|
|
64
|
+
if (working.length === 0) return null;
|
|
65
|
+
return (
|
|
66
|
+
<ul className="bonsai-working">
|
|
67
|
+
{working.map((item) => {
|
|
68
|
+
const areas = [...new Set([...item.paths].map(areaOf))].slice(0, 4);
|
|
69
|
+
return (
|
|
70
|
+
<li key={item.agent} data-agent-slot={colors.get(item.agent)?.color}>
|
|
71
|
+
<AgentMark name={item.agent} colors={colors} />
|
|
72
|
+
<div>
|
|
73
|
+
<p>
|
|
74
|
+
<AgentName name={item.agent} colors={colors} />{" "}
|
|
75
|
+
<span className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
|
|
76
|
+
on
|
|
77
|
+
</span>{" "}
|
|
78
|
+
<span className="font-mono text-sm bonsai-ref">
|
|
79
|
+
{item.repo.includes("--") ? `${item.agent}/` : ""}
|
|
80
|
+
{item.ref}
|
|
81
|
+
</span>
|
|
82
|
+
</p>
|
|
83
|
+
<p className="text-xs bonsai-working-areas">
|
|
84
|
+
{areas.map((area) => (
|
|
85
|
+
<code key={area}>{area}</code>
|
|
86
|
+
))}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
<time className="text-xs text-[var(--stipe-fg-muted)] bonsai-feed-time">
|
|
90
|
+
{relativeTime(item.at)}
|
|
91
|
+
</time>
|
|
92
|
+
</li>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</ul>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
import { useEffect, useReducer } from "react";
|