Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

227 linesCodeBlameRaw
1/**
2 * Docs and code: citing code in a doc (the Cite code dialog), and showing
3 * a project's docs folder in Artifacts.
4 */
5import { DOC_CITATION_KIND_LABELS, type DocCitationKind, type DocRepoSpace } from "@g1t/contracts";
6import { FileCode2, FolderGit2 } from "lucide-react";
7import { useEffect, useState } from "react";
8
9import { ErrorText, Field, Input } from "../../ui";
10import { Button } from "../../ui/button";
11import { Combobox } from "../../ui/combobox";
12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
13import { SelectField } from "../../ui/select";
14import { foliosQuery as docsQuery, foliosRequest as docsRequest } from "../actions";
15
16export type RepoChoice = { repo: string; default_branch: string; private: boolean };
17
18/** The workspace's repositories the viewer can read, asked for once the dialog that needs them opens. */
19export function useRepoChoices(slug: string, wanted: boolean): { repos: RepoChoice[] | null; error: string | null } {
20 const [repos, setRepos] = useState<RepoChoice[] | null>(null);
21 const [error, setError] = useState<string | null>(null);
22 useEffect(() => {
23 if (!wanted || repos) return;
24 let cancelled = false;
25 void docsQuery<RepoChoice[]>(slug, { repos: "1" }).then((r) => {
26 if (cancelled) return;
27 if (r.ok) setRepos(r.value);
28 else setError(r.error.message);
29 });
30 return () => {
31 cancelled = true;
32 };
33 }, [slug, wanted, repos]);
34 return { repos, error };
35}
36
37function RepoPicker({ repos, value, onChange, id }: { repos: RepoChoice[] | null; value: string; onChange: (repo: string) => void; id?: string }) {
38 return (
39 <Combobox
40 id={id}
41 value={value}
42 onValueChange={onChange}
43 placeholder={repos ? "Choose a repository" : "Loading repositories…"}
44 searchPlaceholder="Find a repository"
45 emptyText="No repository you can read matches."
46 disabled={!repos}
47 options={(repos ?? []).map((r) => ({ value: r.repo, label: r.repo, description: r.private ? "Private" : undefined, icon: <FolderGit2 size={14} /> }))}
48 className="w-full"
49 />
50 );
51}
52
53/** What the Cite code dialog inserts: a citation chip's attributes. */
54export type CitationInput = { repo: string; path: string; kind: DocCitationKind; label: string; ref: string };
55
56const LABEL_HINTS: Record<DocCitationKind, string> = {
57 path: "",
58 symbol: "The function, type or class, e.g. exportCsv",
59 endpoint: "The method and route, e.g. POST /v1/exports",
60 env: "The variable's name, e.g. EXPORT_BUCKET",
61};
62
63/**
64 * Cite code: a repository, a path in it (a file, a folder, or a pattern
65 * like `src/export/**`), and what there the doc describes. The citation
66 * is pinned to the default branch's head, and the doc is marked possibly
67 * out of date when a merge changes it.
68 */
69export function CiteDialog({
70 slug,
71 open,
72 onOpenChange,
73 onCite,
74 projects = [],
75}: {
76 slug: string;
77 open: boolean;
78 onOpenChange: (open: boolean) => void;
79 onCite: (c: CitationInput) => void;
80 projects?: string[];
81}) {
82 const { repos, error: loadError } = useRepoChoices(slug, open);
83 const [repo, setRepo] = useState("");
84 const [path, setPath] = useState("");
85 const [kind, setKind] = useState<DocCitationKind>("path");
86 const [label, setLabel] = useState("");
87 const [busy, setBusy] = useState(false);
88 const [error, setError] = useState<string | null>(null);
89 useEffect(() => {
90 if (!open) return;
91 setError(null);
92 setPath("");
93 setLabel("");
94 setKind("path");
95 }, [open]);
96 // The doc's own project first, when it has one.
97 useEffect(() => {
98 if (!repo && repos?.length) setRepo(repos.find((r) => projects.includes(r.repo))?.repo ?? repos[0]!.repo);
99 }, [repos, repo, projects]);
100 const submit = async () => {
101 if (!repo || !path.trim()) return setError("Choose a repository and name a path in it.");
102 if (kind !== "path" && !label.trim()) return setError(`Name the ${kind === "env" ? "variable" : kind}.`);
103 setBusy(true);
104 setError(null);
105 const found = await docsQuery<{ repo: string; path: string; ref: string | null }>(slug, { cite: repo, path: path.trim() });
106 setBusy(false);
107 if (!found.ok) return setError(found.error.message);
108 onCite({ repo: found.value.repo, path: found.value.path, kind, label: kind === "path" ? "" : label.trim(), ref: found.value.ref ?? "" });
109 onOpenChange(false);
110 };
111 return (
112 <Dialog open={open} onOpenChange={onOpenChange}>
113 <DialogContent className="max-w-lg">
114 <DialogHeader>
115 <DialogTitle className="flex items-center gap-2">
116 <FileCode2 size={16} /> Cite code
117 </DialogTitle>
118 <DialogDescription>
119 Link this doc to the code it describes. When a merged pull request changes it, the doc&apos;s owner hears that it may be out of date.
120 </DialogDescription>
121 </DialogHeader>
122 <form
123 className="space-y-4"
124 onSubmit={(e) => {
125 e.preventDefault();
126 void submit();
127 }}
128 >
129 <Field label="Repository">
130 <RepoPicker repos={repos} value={repo} onChange={setRepo} />
131 </Field>
132 <Field label="Path" hint="A file, a folder (everything in it), or a pattern such as src/export/** or *.sql.">
133 <Input
134 value={path}
135 onChange={(e) => setPath(e.target.value)}
136 placeholder="src/export.ts"
137 autoComplete="off"
138 spellCheck={false}
139 className="font-mono"
140 />
141 </Field>
142 <Field label="What it describes">
143 <SelectField
144 value={kind}
145 onValueChange={(v) => setKind(v as DocCitationKind)}
146 options={(Object.keys(DOC_CITATION_KIND_LABELS) as DocCitationKind[]).map((k) => ({ value: k, label: DOC_CITATION_KIND_LABELS[k] }))}
147 className="w-full"
148 aria-label="What it describes"
149 />
150 </Field>
151 {kind !== "path" && (
152 <Field label={kind === "symbol" ? "Symbol" : kind === "endpoint" ? "Endpoint" : "Variable"} hint={LABEL_HINTS[kind]}>
153 <Input value={label} onChange={(e) => setLabel(e.target.value)} autoComplete="off" spellCheck={false} className="font-mono" />
154 </Field>
155 )}
156 {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
157 <DialogFooter>
158 <Button type="submit" variant="accent" disabled={busy || !repos}>
159 {busy ? "Checking…" : "Cite"}
160 </Button>
161 </DialogFooter>
162 </form>
163 </DialogContent>
164 </Dialog>
165 );
166}
167
168/** Show a project's docs: choose a repository the viewer can read; its `docs/` folder and README appear in the sidebar and search. */
169export function RepoDocsDialog({
170 slug,
171 open,
172 onOpenChange,
173 shown,
174 onAdded,
175}: {
176 slug: string;
177 open: boolean;
178 onOpenChange: (open: boolean) => void;
179 shown: string[];
180 onAdded: (space: DocRepoSpace) => void;
181}) {
182 const { repos, error: loadError } = useRepoChoices(slug, open);
183 const [repo, setRepo] = useState("");
184 const [busy, setBusy] = useState(false);
185 const [error, setError] = useState<string | null>(null);
186 const choices = repos?.filter((r) => !shown.includes(r.repo)) ?? null;
187 return (
188 <Dialog open={open} onOpenChange={onOpenChange}>
189 <DialogContent className="max-w-lg">
190 <DialogHeader>
191 <DialogTitle className="flex items-center gap-2">
192 <FolderGit2 size={16} /> Show a project&apos;s docs
193 </DialogTitle>
194 <DialogDescription>
195 The repository&apos;s docs folder and README, read from its default branch, appear beside your spaces and in search, and follow every push. Only
196 people who can read the repository see them. Changes go through the repository.
197 </DialogDescription>
198 </DialogHeader>
199 <form
200 className="space-y-4"
201 onSubmit={async (e) => {
202 e.preventDefault();
203 if (!repo) return;
204 setBusy(true);
205 setError(null);
206 const added = await docsRequest<DocRepoSpace>(slug, "add_repo_space", { repo });
207 setBusy(false);
208 if (!added.ok) return setError(added.error.message);
209 onAdded(added.value);
210 onOpenChange(false);
211 }}
212 >
213 <Field label="Repository">
214 <RepoPicker repos={choices} value={repo} onChange={setRepo} />
215 </Field>
216 {choices && choices.length === 0 && <p className="text-xs text-faint">Every repository you can read is already shown.</p>}
217 {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
218 <DialogFooter>
219 <Button type="submit" variant="accent" disabled={busy || !repo}>
220 {busy ? "Reading its docs…" : "Show its docs"}
221 </Button>
222 </DialogFooter>
223 </form>
224 </DialogContent>
225 </Dialog>
226 );
227}