`<root>/website` is now checked, and the block says which of three things it is: not there, there but not a checkout, or a checkout with its branch and its head commit. the middle one is the state worth being loud about — cloning will refuse, and the reason is something already sitting in the way. SSH, hardcoded. the repo is private, so https asks for a username with nobody at a terminal to answer, and the SSH remote is already authenticated by the owner's key. hardcoded because this plugin administers exactly one website: a field for it would be a setting nobody changes and one more thing that can point somewhere wrong. when the checkout's origin is NOT that URL, the block says so — shown always it is noise, shown on a mismatch it answers why an update pulled something unexpected. NOTHING HERE MAY HANG. these run inside an HTTP request, so git gets GIT_TERMINAL_PROMPT=0 and ssh gets BatchMode=yes. without them a key that lost access does not fail, it BLOCKS forever waiting for a password at a terminal that does not exist, and the panel spins until somebody restarts the server. there is a five-minute kill on top for everything else that can wedge. update is `--ff-only`, never a merge. a merge commit made by a button in a checkout nobody is watching is a thing somebody finds days later; refusing is recoverable and legible. it also refuses on a dirty tree rather than failing halfway — and dirty is `--untracked-files=no`, because a build artefact or an uncommitted .env is not a reason to make Update permanently unavailable. clone takes FULL history, not --depth 1. this is a working checkout somebody will pull into and read the log of, and shallow turns both into a support question. it sits inside Root location rather than in a block of its own: the path and the thing at the path are one question, and split apart somebody could change the root in one place and read a status about the old one in the other, both looking equally current. hidden while editing, because it describes what is SAVED. git's own output is shown verbatim on failure. "Permission denied (publickey)" says exactly what is wrong; anything written in its place would be a guess at which of a dozen causes it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
5.5 KiB
TypeScript
146 lines
5.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';
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
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 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);
|
|
} 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. Scrolled rather than truncated — the useful line of a
|
|
clone failure is usually the last one. */}
|
|
{output && (
|
|
<pre
|
|
className={`max-h-32 overflow-auto rounded-md border border-border p-2 font-mono text-[11px] whitespace-pre-wrap ${
|
|
failed ? 'text-destructive' : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
{output}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|