| 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 | */ |
| 5 | import { DOC_CITATION_KIND_LABELS, type DocCitationKind, type DocRepoSpace } from "@g1t/contracts"; |
| 6 | import { FileCode2, FolderGit2 } from "lucide-react"; |
| 7 | import { useEffect, useState } from "react"; |
| 8 | |
| 9 | import { ErrorText, Field, Input } from "../../ui"; |
| 10 | import { Button } from "../../ui/button"; |
| 11 | import { Combobox } from "../../ui/combobox"; |
| 12 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog"; |
| 13 | import { SelectField } from "../../ui/select"; |
| 14 | import { foliosQuery as docsQuery, foliosRequest as docsRequest } from "../actions"; |
| 15 | |
| 16 | export 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. */ |
| 19 | export 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 | |
| 37 | function 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. */ |
| 54 | export type CitationInput = { repo: string; path: string; kind: DocCitationKind; label: string; ref: string }; |
| 55 | |
| 56 | const 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 | */ |
| 69 | export 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'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. */ |
| 169 | export 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's docs |
| 193 | </DialogTitle> |
| 194 | <DialogDescription> |
| 195 | The repository'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 | } |