Files
fofos-admin/web/WebsiteCheckout.tsx
T
pastilhasandClaude Opus 5 91aae3e981 the output is a console, and it clears itself when it worked
pre/code on black. it is quoting a terminal, so it should look like one — as a
tinted paragraph it read as part of the form, which is exactly what it is not.

IT CLOSES ITSELF ON SUCCESS, after five seconds, 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, and gets no timer at all rather than a long one. it is
the only copy of why, and a transcript that scrolled past is one somebody has to
reproduce in order to read. nothing takes it off the screen except the next attempt.

same `useFlash` as the Saved indicator, so there is one timer implementation and one
number, and the clear-the-previous-timeout property comes along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:57:36 +01:00

157 lines
6.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleAlert, CircleCheck, CircleDashed, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
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 stays until something else happens, because it is the only copy of
* why, and a transcript that scrolled past is one somebody has to reproduce to read.
*/
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 }: { rootPath: string }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Status | null>(null);
const [busy, setBusy] = useState<'clone' | 'update' | null>(null);
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [showing, show] = useFlash();
const load = useCallback(async () => {
try {
setStatus(await clientRef.current.get<Status>('/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 `<root>/website` and a stale one
// describes a directory nobody is looking at any more.
useEffect(() => {
void load();
}, [load, rootPath]);
const act = async (what: 'clone' | 'update') => {
setBusy(what);
setOutput(null);
setFailed(false);
try {
const result = await clientRef.current.post<RunResult>(`/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 (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-start gap-2">
<Icon className={`mt-0.5 size-3.5 shrink-0 ${tone}`} />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="font-mono text-xs break-all">{status.path}</p>
{status.isRepo ? (
<p className="text-xs text-muted-foreground">
{status.branch ?? 'detached'} · {status.head ?? 'no commits'}
{status.dirty && <span className="text-destructive"> · uncommitted changes</span>}
</p>
) : 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.
<p className="text-xs text-destructive">Something is already here, and it is not a git checkout.</p>
) : (
<p className="text-xs text-muted-foreground">Not cloned yet.</p>
)}
{/* 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 && (
<p className="font-mono text-xs break-all text-destructive">origin: {status.remote}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{status.isRepo ? (
<Button variant="outline" size="sm" onClick={() => void act('update')} disabled={busy !== null}>
{busy === 'update' ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
Update
</Button>
) : (
<Button size="sm" onClick={() => void act('clone')} disabled={busy !== null || status.present}>
{busy === 'clone' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<DownloadCloud className="size-3.5" />
)}
Clone
</Button>
)}
</div>
</div>
{/* 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) && (
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
)}
</div>
);
};