Skip to content
331 linesCodeBlameRaw
1/**
2 * Docs and code: citing code in a page (the Cite code dialog), the
3 * header's "Describes" list, the banner on a page possibly out of date,
4 * and showing a project's docs folder in Docs. Plan: docs/WORKSPACE.md,
5 * "Agents and docs" and "Docs and repository docs".
6 */
7import { DOC_CITATION_KIND_LABELS, type DocCitationKind, type DocDescribes, type DocRepoSpace, type DocStaleness } from "@g1t/contracts";
8import { AlertTriangle, Check, FileCode2, FolderGit2, GitCommitHorizontal, GitPullRequest, Plus, X } from "lucide-react";
9import { useEffect, useState } from "react";
10import { Link } from "react-router";
11
12import { citationHref } from "../../lib/docs";
13import { Button, ErrorText, Field, Input, TimeAgo } from "../ui";
14import { Combobox } from "../ui/combobox";
15import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
16import { Hint } from "../ui/hint";
17import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
18import { SelectField } from "../ui/select";
19import { docsQuery, docsRequest } from "./actions";
20
21export type RepoChoice = { repo: string; default_branch: string; private: boolean };
22
23/** The workspace's repositories the viewer can read, asked for once the dialog that needs them opens. */
24export function useRepoChoices(slug: string, wanted: boolean): { repos: RepoChoice[] | null; error: string | null } {
25 const [repos, setRepos] = useState<RepoChoice[] | null>(null);
26 const [error, setError] = useState<string | null>(null);
27 useEffect(() => {
28 if (!wanted || repos) return;
29 let cancelled = false;
30 void docsQuery<RepoChoice[]>(slug, { repos: "1" }).then((r) => {
31 if (cancelled) return;
32 if (r.ok) setRepos(r.value);
33 else setError(r.error.message);
34 });
35 return () => {
36 cancelled = true;
37 };
38 }, [slug, wanted, repos]);
39 return { repos, error };
40}
41
42function RepoPicker({ repos, value, onChange, id }: { repos: RepoChoice[] | null; value: string; onChange: (repo: string) => void; id?: string }) {
43 return (
44 <Combobox
45 id={id}
46 value={value}
47 onValueChange={onChange}
48 placeholder={repos ? "Choose a repository" : "Loading repositories…"}
49 searchPlaceholder="Find a repository"
50 emptyText="No repository you can read matches."
51 disabled={!repos}
52 options={(repos ?? []).map((r) => ({ value: r.repo, label: r.repo, description: r.private ? "Private" : undefined, icon: <FolderGit2 size={14} /> }))}
53 className="w-full"
54 />
55 );
56}
57
58/** What the Cite code dialog inserts: a citation chip's attributes. */
59export type CitationInput = { repo: string; path: string; kind: DocCitationKind; label: string; ref: string };
60
61const LABEL_HINTS: Record<DocCitationKind, string> = {
62 path: "",
63 symbol: "The function, type or class, e.g. exportCsv",
64 endpoint: "The method and route, e.g. POST /v1/exports",
65 env: "The variable's name, e.g. EXPORT_BUCKET",
66};
67
68/**
69 * Cite code: a repository, a path in it (a file, a folder, or a pattern
70 * like `src/export/**`), and what there the page describes. The citation
71 * is pinned to the default branch's head, and the page is marked possibly
72 * out of date when a merge changes it.
73 */
74export function CiteDialog({ slug, open, onOpenChange, onCite, projects = [] }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; onCite: (c: CitationInput) => void; projects?: string[] }) {
75 const { repos, error: loadError } = useRepoChoices(slug, open);
76 const [repo, setRepo] = useState("");
77 const [path, setPath] = useState("");
78 const [kind, setKind] = useState<DocCitationKind>("path");
79 const [label, setLabel] = useState("");
80 const [busy, setBusy] = useState(false);
81 const [error, setError] = useState<string | null>(null);
82 useEffect(() => {
83 if (!open) return;
84 setError(null);
85 setPath("");
86 setLabel("");
87 setKind("path");
88 }, [open]);
89 // The page's own project first, when it has one.
90 useEffect(() => {
91 if (!repo && repos?.length) setRepo(repos.find((r) => projects.includes(r.repo))?.repo ?? repos[0]!.repo);
92 }, [repos, repo, projects]);
93 const submit = async () => {
94 if (!repo || !path.trim()) return setError("Choose a repository and name a path in it.");
95 if (kind !== "path" && !label.trim()) return setError(`Name the ${kind === "env" ? "variable" : kind}.`);
96 setBusy(true);
97 setError(null);
98 const found = await docsQuery<{ repo: string; path: string; ref: string | null }>(slug, { cite: repo, path: path.trim() });
99 setBusy(false);
100 if (!found.ok) return setError(found.error.message);
101 onCite({ repo: found.value.repo, path: found.value.path, kind, label: kind === "path" ? "" : label.trim(), ref: found.value.ref ?? "" });
102 onOpenChange(false);
103 };
104 return (
105 <Dialog open={open} onOpenChange={onOpenChange}>
106 <DialogContent className="max-w-lg">
107 <DialogHeader>
108 <DialogTitle className="flex items-center gap-2">
109 <FileCode2 size={16} /> Cite code
110 </DialogTitle>
111 <DialogDescription>Link this page to the code it describes. When a merged pull request changes it, the page&apos;s owners hear that it may be out of date.</DialogDescription>
112 </DialogHeader>
113 <form
114 className="space-y-4"
115 onSubmit={(e) => {
116 e.preventDefault();
117 void submit();
118 }}
119 >
120 <Field label="Repository">
121 <RepoPicker repos={repos} value={repo} onChange={setRepo} />
122 </Field>
123 <Field label="Path" hint="A file, a folder (everything in it), or a pattern such as src/export/** or *.sql.">
124 <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="src/export.ts" autoComplete="off" spellCheck={false} className="font-mono" />
125 </Field>
126 <Field label="What it describes">
127 <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" />
128 </Field>
129 {kind !== "path" && (
130 <Field label={kind === "symbol" ? "Symbol" : kind === "endpoint" ? "Endpoint" : "Variable"} hint={LABEL_HINTS[kind]}>
131 <Input value={label} onChange={(e) => setLabel(e.target.value)} autoComplete="off" spellCheck={false} className="font-mono" />
132 </Field>
133 )}
134 {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
135 <DialogFooter>
136 <Button type="submit" variant="accent" disabled={busy || !repos}>
137 {busy ? "Checking…" : "Cite"}
138 </Button>
139 </DialogFooter>
140 </form>
141 </DialogContent>
142 </Dialog>
143 );
144}
145
146/** The header's "Describes": which code the page is about, editable by anyone who can edit it. */
147export function Describes({ slug, describes, editable, onSave }: { slug: string; describes: DocDescribes[]; editable: boolean; onSave: (next: DocDescribes[]) => Promise<unknown> }) {
148 const [open, setOpen] = useState(false);
149 const { repos } = useRepoChoices(slug, open);
150 const [repo, setRepo] = useState("");
151 const [path, setPath] = useState("");
152 useEffect(() => {
153 if (!repo && repos?.length) setRepo(describes[0]?.repo ?? repos[0]!.repo);
154 }, [repos, repo, describes]);
155 if (!describes.length && !editable) return null;
156 const add = async () => {
157 const clean = path.trim().replace(/^\/+|\/+$/g, "");
158 if (!repo || !clean) return;
159 await onSave([...describes, { repo, path: clean }]);
160 setPath("");
161 };
162 return (
163 <span className="flex flex-wrap items-center gap-1.5">
164 <span>Describes</span>
165 {describes.map((d) => (
166 <Link key={`${d.repo}:${d.path}`} to={citationHref({ repo: d.repo, path: d.path, ref: null })} className="inline-flex items-center gap-1 rounded-full border border-line px-2 py-0.5 font-mono text-[0.6875rem] text-muted hover:border-line-strong hover:text-fg">
167 <FileCode2 size={11} aria-hidden="true" />
168 {d.repo}:{d.path}
169 </Link>
170 ))}
171 {editable && (
172 <Popover open={open} onOpenChange={setOpen}>
173 <Hint label={describes.length ? "Change what this page describes" : "Say which code this page describes"}>
174 <PopoverTrigger asChild>
175 <button type="button" aria-label="Edit what this page describes" className="inline-flex size-5 items-center justify-center rounded-full border border-dashed border-line text-faint hover:border-line-strong hover:text-fg">
176 <Plus size={11} />
177 </button>
178 </PopoverTrigger>
179 </Hint>
180 <PopoverContent align="start" className="w-96 p-4 text-sm">
181 <p className="font-medium">Code this page describes</p>
182 <p className="mt-1 text-xs leading-relaxed text-muted">When a merged pull request changes any of it, the page is marked possibly out of date and its owners are told.</p>
183 {describes.length > 0 && (
184 <ul className="mt-3 space-y-1">
185 {describes.map((d, i) => (
186 <li key={`${d.repo}:${d.path}`} className="flex items-center gap-2 rounded-md bg-raised px-2 py-1 font-mono text-xs">
187 <span className="min-w-0 grow truncate">
188 {d.repo}:{d.path}
189 </span>
190 <button type="button" aria-label={`Remove ${d.repo}:${d.path}`} onClick={() => void onSave(describes.filter((_, j) => j !== i))} className="text-faint hover:text-danger">
191 <X size={13} />
192 </button>
193 </li>
194 ))}
195 </ul>
196 )}
197 <form
198 className="mt-3 space-y-2"
199 onSubmit={(e) => {
200 e.preventDefault();
201 void add();
202 }}
203 >
204 <RepoPicker repos={repos} value={repo} onChange={setRepo} />
205 <div className="flex gap-2">
206 <Input value={path} onChange={(e) => setPath(e.target.value)} placeholder="src/export or src/**/*.sql" aria-label="Path" autoComplete="off" spellCheck={false} className="font-mono" />
207 <Button type="submit" variant="quiet" disabled={!repo || !path.trim()}>
208 Add
209 </Button>
210 </div>
211 </form>
212 </PopoverContent>
213 </Popover>
214 )}
215 </span>
216 );
217}
218
219function changeLink(c: DocStaleness["changes"][number]): { href: string; label: string } | null {
220 if (!c.visible || !c.repo) return null;
221 if (c.pull) return { href: `/${c.repo}/pull/${c.pull.number}`, label: `${c.repo}#${c.pull.number}` };
222 if (c.commit) return { href: `/${c.repo}/commit/${c.commit}`, label: `${c.repo}@${c.commit.slice(0, 7)}` };
223 return null;
224}
225
226/**
227 * "Possibly out of date since acme/web#431 changed src/export.ts":
228 * the newest change the reader can see, the rest counted; Review changes
229 * opens it; Mark as current clears it for everyone.
230 */
231export function StaleBanner({ staleness, canMark, onMark }: { staleness: DocStaleness; canMark: boolean; onMark: () => Promise<unknown> }) {
232 const [busy, setBusy] = useState(false);
233 const latest = staleness.changes[0];
234 if (!latest) return null;
235 const link = changeLink(latest);
236 const more = staleness.changes.length - 1;
237 return (
238 <div role="status" className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-warn/30 bg-warn/8 px-3 py-2 text-sm">
239 <AlertTriangle size={15} className="shrink-0 text-warn" aria-hidden="true" />
240 <span className="min-w-0 grow text-fg-soft">
241 Possibly out of date since{" "}
242 {link ? (
243 <>
244 <Link to={link.href} className="font-medium text-fg hover:underline">
245 {link.label}
246 </Link>
247 {latest.paths.length > 0 && (
248 <>
249 {" "}
250 changed <span className="font-mono text-[0.8125rem] text-fg">{latest.paths[0]}</span>
251 {latest.paths.length > 1 && ` and ${latest.paths.length - 1} more`}
252 </>
253 )}
254 </>
255 ) : (
256 "a change you can't see touched code it cites"
257 )}
258 {more > 0 && <span className="text-muted"> · {more === 1 ? "1 more change" : `${more} more changes`}</span>}
259 <span className="text-faint">
260 {" "}
261 · <TimeAgo at={latest.at} />
262 </span>
263 </span>
264 {link && (
265 <Link to={latest.pull ? `${link.href}?tab=changes` : link.href} className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-fg hover:bg-raised">
266 {latest.pull ? <GitPullRequest size={13} /> : <GitCommitHorizontal size={13} />} Review changes
267 </Link>
268 )}
269 {canMark && (
270 <button
271 type="button"
272 disabled={busy}
273 onClick={async () => {
274 setBusy(true);
275 await onMark();
276 setBusy(false);
277 }}
278 className="inline-flex h-7 items-center gap-1.5 rounded-md bg-warn/15 px-2.5 text-xs font-medium text-warn hover:bg-warn/25 disabled:opacity-60"
279 >
280 <Check size={13} /> {busy ? "Marking…" : "Mark as current"}
281 </button>
282 )}
283 </div>
284 );
285}
286
287/** Show a project's docs: choose a repository the viewer can read; its `docs/` folder and README appear in the sidebar and search. */
288export function RepoDocsDialog({ slug, open, onOpenChange, shown, onAdded }: { slug: string; open: boolean; onOpenChange: (open: boolean) => void; shown: string[]; onAdded: (space: DocRepoSpace) => void }) {
289 const { repos, error: loadError } = useRepoChoices(slug, open);
290 const [repo, setRepo] = useState("");
291 const [busy, setBusy] = useState(false);
292 const [error, setError] = useState<string | null>(null);
293 const choices = repos?.filter((r) => !shown.includes(r.repo)) ?? null;
294 return (
295 <Dialog open={open} onOpenChange={onOpenChange}>
296 <DialogContent className="max-w-lg">
297 <DialogHeader>
298 <DialogTitle className="flex items-center gap-2">
299 <FolderGit2 size={16} /> Show a project&apos;s docs
300 </DialogTitle>
301 <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>
302 </DialogHeader>
303 <form
304 className="space-y-4"
305 onSubmit={async (e) => {
306 e.preventDefault();
307 if (!repo) return;
308 setBusy(true);
309 setError(null);
310 const added = await docsRequest<DocRepoSpace>(slug, "add_repo_space", { repo });
311 setBusy(false);
312 if (!added.ok) return setError(added.error.message);
313 onAdded(added.value);
314 onOpenChange(false);
315 }}
316 >
317 <Field label="Repository">
318 <RepoPicker repos={choices} value={repo} onChange={setRepo} />
319 </Field>
320 {choices && choices.length === 0 && <p className="text-xs text-faint">Every repository you can read is already shown.</p>}
321 {(error || loadError) && <ErrorText>{error ?? loadError}</ErrorText>}
322 <DialogFooter>
323 <Button type="submit" variant="accent" disabled={busy || !repo}>
324 {busy ? "Reading its docs…" : "Show its docs"}
325 </Button>
326 </DialogFooter>
327 </form>
328 </DialogContent>
329 </Dialog>
330 );
331}