Skip to content
185 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. Plan: docs/WORKSPACE.md, "Agents
4 * and docs" and "Docs and repository docs".
5 */
6import { DOC_CITATION_KIND_LABELS, type DocCitationKind, type DocRepoSpace } from "@g1t/contracts";
7import { FileCode2, FolderGit2 } from "lucide-react";
8import { useEffect, useState } from "react";
9
10import { Button, ErrorText, Field, Input } from "../../ui";
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({ slug, open, onOpenChange, onCite, projects = [] }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; onCite: (c: CitationInput) => void; projects?: string[] }) {
70 const { repos, error: loadError } = useRepoChoices(slug, open);
71 const [repo, setRepo] = useState("");
72 const [path, setPath] = useState("");
73 const [kind, setKind] = useState<DocCitationKind>("path");
74 const [label, setLabel] = useState("");
75 const [busy, setBusy] = useState(false);
76 const [error, setError] = useState<string | null>(null);
77 useEffect(() => {
78 if (!open) return;
79 setError(null);
80 setPath("");
81 setLabel("");
82 setKind("path");
83 }, [open]);
84 // The doc's own project first, when it has one.
85 useEffect(() => {
86 if (!repo && repos?.length) setRepo(repos.find((r) => projects.includes(r.repo))?.repo ?? repos[0]!.repo);
87 }, [repos, repo, projects]);
88 const submit = async () => {
89 if (!repo || !path.trim()) return setError("Choose a repository and name a path in it.");
90 if (kind !== "path" && !label.trim()) return setError(`Name the ${kind === "env" ? "variable" : kind}.`);
91 setBusy(true);
92 setError(null);
93 const found = await docsQuery<{ repo: string; path: string; ref: string | null }>(slug, { cite: repo, path: path.trim() });
94 setBusy(false);
95 if (!found.ok) return setError(found.error.message);
96 onCite({ repo: found.value.repo, path: found.value.path, kind, label: kind === "path" ? "" : label.trim(), ref: found.value.ref ?? "" });
97 onOpenChange(false);
98 };
99 return (
100 <Dialog open={open} onOpenChange={onOpenChange}>
101 <DialogContent className="max-w-lg">
102 <DialogHeader>
103 <DialogTitle className="flex items-center gap-2">
104 <FileCode2 size={16} /> Cite code
105 </DialogTitle>
106 <DialogDescription>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.</DialogDescription>
107 </DialogHeader>
108 <form
109 className="space-y-4"
110 onSubmit={(e) => {
111 e.preventDefault();
112 void submit();
113 }}
114 >
115 <Field label="Repository">
116 <RepoPicker repos={repos} value={repo} onChange={setRepo} />
117 </Field>
118 <Field label="Path" hint="A file, a folder (everything in it), or a pattern such as src/export/** or *.sql.">
119 <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="src/export.ts" autoComplete="off" spellCheck={false} className="font-mono" />
120 </Field>
121 <Field label="What it describes">
122 <SelectField value={kind} onValueChange={(v) => setKind(v as DocCitationKind)} options={(Object.keys(DOC_CITATION_KIND_LABELS) as DocCitationKind[]).map((k) => ({ value: k, label: DOC_CITATION_KIND_LABELS[k] }))} className="w-full" aria-label="What it describes" />
123 </Field>
124 {kind !== "path" && (
125 <Field label={kind === "symbol" ? "Symbol" : kind === "endpoint" ? "Endpoint" : "Variable"} hint={LABEL_HINTS[kind]}>
126 <Input value={label} onChange={(e) => setLabel(e.target.value)} autoComplete="off" spellCheck={false} className="font-mono" />
127 </Field>
128 )}
129 {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
130 <DialogFooter>
131 <Button type="submit" variant="accent" disabled={busy || !repos}>
132 {busy ? "Checking…" : "Cite"}
133 </Button>
134 </DialogFooter>
135 </form>
136 </DialogContent>
137 </Dialog>
138 );
139}
140
141/** Show a project's docs: choose a repository the viewer can read; its `docs/` folder and README appear in the sidebar and search. */
142export function RepoDocsDialog({ slug, open, onOpenChange, shown, onAdded }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; shown: string[]; onAdded: (space: DocRepoSpace) => void }) {
143 const { repos, error: loadError } = useRepoChoices(slug, open);
144 const [repo, setRepo] = useState("");
145 const [busy, setBusy] = useState(false);
146 const [error, setError] = useState<string | null>(null);
147 const choices = repos?.filter((r) => !shown.includes(r.repo)) ?? null;
148 return (
149 <Dialog open={open} onOpenChange={onOpenChange}>
150 <DialogContent className="max-w-lg">
151 <DialogHeader>
152 <DialogTitle className="flex items-center gap-2">
153 <FolderGit2 size={16} /> Show a project&apos;s docs
154 </DialogTitle>
155 <DialogDescription>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 people who can read the repository see them. Changes go through the repository.</DialogDescription>
156 </DialogHeader>
157 <form
158 className="space-y-4"
159 onSubmit={async (e) => {
160 e.preventDefault();
161 if (!repo) return;
162 setBusy(true);
163 setError(null);
164 const added = await docsRequest<DocRepoSpace>(slug, "add_repo_space", { repo });
165 setBusy(false);
166 if (!added.ok) return setError(added.error.message);
167 onAdded(added.value);
168 onOpenChange(false);
169 }}
170 >
171 <Field label="Repository">
172 <RepoPicker repos={choices} value={repo} onChange={setRepo} />
173 </Field>
174 {choices && choices.length === 0 && <p className="text-xs text-faint">Every repository you can read is already shown.</p>}
175 {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
176 <DialogFooter>
177 <Button type="submit" variant="accent" disabled={busy || !repo}>
178 {busy ? "Reading its docs…" : "Show its docs"}
179 </Button>
180 </DialogFooter>
181 </form>
182 </DialogContent>
183 </Dialog>
184 );
185}