import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, CircleAlert, CircleCheck, CircleDashed, Copy, DownloadCloud, Loader2, RefreshCw } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { copyToClipboard } from 'helpers/clipboard'; import { Button } from '@/components/ui/button'; import { readableError } from './errors'; import { useFlash } from './useFlash'; /** * The website checkout under the root location: is it there, clone it, update it. * * ── It lives inside Root location on purpose ── * * The path and the thing at the path are one question. Split into its own block, somebody could change * the root in one place and read a checkout status about the old one in another — and the two would * look equally current. * * ── git's own output, verbatim, when something fails ── * * "Permission denied (publickey)" says exactly what is wrong. Anything this could write in its place * would be a guess at which of a dozen causes it was, and the guess is what sends somebody looking in * the wrong direction. * * ── The console closes itself on success, and never on failure ── * * A successful clone or update is worth WATCHING and not worth keeping: "74 packages installed" has * been read by the time it finishes, and left on screen it becomes furniture that outlives its own * truth — still sitting there after the root has been changed underneath it. * * A failure is the opposite. It gets no timer at all rather than a long one — the only thing that * takes it off the screen is starting the next attempt, which is the one moment it stops being true. * That includes the half-done case, which is the most important of the three: a clone that worked * followed by an install that did not is a failure, `ok` is false, and the transcript of both stays. * * ── And it can be copied ── * * A failure is something somebody pastes — into a search, into a message, back to whoever wrote the * thing that broke. Selecting text inside a scrolled box, on a phone, is the point at which people * take a screenshot of an error message instead, and a screenshot of a stack trace helps nobody. */ type Status = { path: string; present: boolean; isRepo: boolean; branch: string | null; head: string | null; remote: string | null; dirty: boolean; cloneUrl: string; }; type RunResult = { ok: boolean; output: string; status: Status }; export const WebsiteCheckout = ({ rootPath, onSettled, }: { rootPath: string; onSettled?: (settled: boolean) => void; }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [status, setStatus] = useState(null); const [busy, setBusy] = useState<'clone' | 'update' | null>(null); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); const [showing, show] = useFlash(); const [copied, flashCopied] = useFlash(1500); const load = useCallback(async () => { try { setStatus(await clientRef.current.get('/fofos-admin/_config/website')); } catch { // Owner-only, so a member gets nothing here. The block above already says why, and repeating it // would be two refusals for one cause. setStatus(null); } }, []); // Re-checked whenever the ROOT changes, because the answer is about `/website` and a stale one // describes a directory nobody is looking at any more. useEffect(() => { void load(); }, [load, rootPath]); // One bit upward — see the note in `MediaSync`. A checkout that is present but not a repository is // NOT settled: something is in the way, and that is exactly the state worth staying open for. useEffect(() => { onSettled?.(Boolean(status?.isRepo)); }, [status?.isRepo, onSettled]); const act = async (what: 'clone' | 'update') => { setBusy(what); setOutput(null); setFailed(false); try { const result = await clientRef.current.post(`/fofos-admin/_config/website/${what}`, {}); setStatus(result.status); setOutput(result.output || null); setFailed(!result.ok); // Only on the way out. A failure has no timer at all rather than a long one — nothing should be // able to take it off the screen except the next attempt. if (result.ok) show(); } catch (failure) { // A 400 carries git's own words in the body; `readableError` surfaces them. setOutput(readableError(failure)); setFailed(true); void load(); } finally { setBusy(null); } }; if (!status) return null; const Icon = status.isRepo ? CircleCheck : status.present ? CircleAlert : CircleDashed; const tone = status.isRepo ? 'text-success' : status.present ? 'text-destructive' : 'text-muted-foreground'; return (

{status.path}

{status.isRepo ? (

{status.branch ?? 'detached'} · {status.head ?? 'no commits'} {status.dirty && · uncommitted changes}

) : status.present ? ( // Present but not a checkout is the one state worth being loud about: cloning will refuse, // and the reason is something already sitting there.

Something is already here, and it is not a git checkout.

) : (

Not cloned yet.

)} {/* Only when it is NOT what this plugin would clone. Shown always it is noise; shown on a mismatch it is the answer to why an update is pulling something unexpected. */} {status.isRepo && status.remote && status.remote !== status.cloneUrl && (

origin: {status.remote}

)}
{status.isRepo ? ( ) : ( )}
{/* git's own words, in git's own shape — a console, so it looks like the thing it is quoting rather than like a paragraph of the form. Scrolled, not truncated: the useful line of a failure is usually the last one. */} {output && (failed || showing) && (
            {output}
          
{/* Pinned to the corner of the BOX, not scrolled with the text — a copy button that leaves the viewport when the transcript is long is missing exactly when the transcript is worth copying. `pr-10` above keeps the text out from under it. Its own dark background because it sits over the output rather than beside it. */}
)}
); };