Everything under it is in order most of the time, and a card that is four paragraphs of "fine" pushes the settings that are not fine off the screen. So it shuts, with a check on the right, and opens on a click. "Finished" is three bits reported upward by the parts that own them: the path is saved, the checkout is a repository, and media/ both exists and is current. None of those is knowable in the parent — each child fetches its own status — and lifting all of it up to answer one question would be a far worse trade than three booleans. It is only collapsible while settled. A block you can shut on unfinished setup is a way to hide the thing you came here to fix. And the children are NOT RENDERED while it is shut rather than hidden, because a collapsed block whose children still poll is a settled panel making requests all afternoon about something nobody is looking at. The `null` in `collapsed` is a sentinel for "nobody has decided", which is what lets a deliberate expand survive. Setting it to false while unsettled spent it on the first render — before any status had arrived — so the block could never fold itself away at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
8.4 KiB
TypeScript
197 lines
8.4 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,
|
|
onSettled,
|
|
}: {
|
|
rootPath: string;
|
|
onSettled?: (settled: boolean) => void;
|
|
}) => {
|
|
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]);
|
|
|
|
// 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<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>
|
|
);
|
|
};
|