Files
fofos-admin/web/WebsiteCheckout.tsx
T
pastilhasandClaude Opus 5 c268e8438a a copy button on the console, and the failure case spelled out
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 screenshot an error instead, and a screenshot
of a stack trace helps nobody.

the button is pinned to the corner of the BOX and not scrolled with the text: one
that leaves the viewport when the transcript is long is missing exactly when the
transcript is worth copying. `pr-10` keeps the output from running underneath it,
and it carries its own dark background because it sits over the console rather
than beside it.

`copyToClipboard` from helpers, the same one CopyField uses, rather than reaching
for navigator.clipboard here.

the no-timer-on-failure rule was already the behaviour; the comment now says the
part that was only implied. the half-done case is the one that matters and it is
named: a clone that worked followed by an install that did not is a failure, `ok`
is false, and the transcript of BOTH stays. the only thing that clears it is
starting the next attempt — the one moment it stops being true.

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

185 lines
8.0 KiB
TypeScript

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 }: { 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 [copied, flashCopied] = useFlash(1500);
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) && (
<div className="relative">
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 pr-10 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
{/* 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. */}
<button
type="button"
onClick={async () => {
if (await copyToClipboard(output)) flashCopied();
}}
aria-label="Copy this output"
title="Copy"
className="absolute top-1.5 right-1.5 rounded border border-border/60 bg-black/80 p-1.5 text-neutral-400 transition-colors hover:text-neutral-100"
>
{copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
</button>
</div>
)}
</div>
);
};