`<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>
199 lines
7.6 KiB
TypeScript
199 lines
7.6 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Check, FolderRoot, Loader2, Pencil } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { readableError } from './errors';
|
|
import { DirectoryPicker } from './DirectoryPicker';
|
|
import { Block } from './Block';
|
|
import { WebsiteCheckout } from './WebsiteCheckout';
|
|
import { useFlash } from './useFlash';
|
|
|
|
/**
|
|
* Where the fofos site is on this machine.
|
|
*
|
|
* ── "Saved" is a moment, not a state ──
|
|
*
|
|
* It appears top-right when a save actually lands and is gone five seconds later. As a permanent line
|
|
* it said nothing — everything on this screen has been saved, all the time — while looking exactly
|
|
* like feedback, so the one moment it meant something was indistinguishable from the rest.
|
|
*
|
|
* "Not set yet" is the opposite and stays: that IS a state, and it is the one worth knowing.
|
|
*
|
|
* ── The default is a suggestion until it is saved ──
|
|
*
|
|
* An unset root reports `<owner home>/fofos` and says so. It is not written to the database by being
|
|
* looked at — a value somebody has never agreed to must not become one they appear to have chosen, and
|
|
* a default that follows the machine is more useful than one frozen on install day.
|
|
*
|
|
* ── Typing and picking are the same field ──
|
|
*
|
|
* The picker fills the field; the field is what saves. So there is one value on screen and one Save,
|
|
* and picking a folder never commits anything by itself — the path is visible and editable before it
|
|
* is stored. It also means a location OUTSIDE the owner's home can still be typed, which the picker
|
|
* cannot reach.
|
|
*
|
|
* ── Two states, and the actions are on their own row ──
|
|
*
|
|
* Reading, it is a path and one Edit. Editing, it is a field and the things you can do to it. The
|
|
* actions sit on the block's BOTTOM row rather than beside the value, so the path gets the full width
|
|
* — it is long, absolute and the thing somebody came here to read, and it was being truncated to make
|
|
* room for buttons that are not needed most of the time.
|
|
*
|
|
* Edit is a mode rather than an always-live field because this is a setting that is looked at far more
|
|
* often than it is changed, and a live input invites a stray keystroke into a path that everything
|
|
* else depends on.
|
|
*/
|
|
|
|
type Settings = { rootPath: string; isSet: boolean };
|
|
|
|
export const RootLocationBlock = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [saved, setSaved] = useState<Settings | null>(null);
|
|
const [value, setValue] = useState('');
|
|
const [editing, setEditing] = useState(false);
|
|
const [picking, setPicking] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [forbidden, setForbidden] = useState(false);
|
|
const [flashing, flash] = useFlash();
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const current = await clientRef.current.get<Settings>('/fofos-admin/_config/root');
|
|
setSaved(current);
|
|
setValue(current.rootPath);
|
|
} catch (failure) {
|
|
// A member looking at an owner-only setting is not a fault and needs a different sentence.
|
|
if ((failure as { status?: number })?.status === 403) setForbidden(true);
|
|
else setError(readableError(failure));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const result = await clientRef.current.put<Settings>('/fofos-admin/_config/root', { path: value });
|
|
setSaved(result);
|
|
// From the server, not from what was typed — `~` is expanded and trailing slashes go, so the
|
|
// field must show what was actually stored rather than what was sent.
|
|
setValue(result.rootPath);
|
|
setEditing(false);
|
|
setPicking(false);
|
|
flash();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const shell = (children: React.ReactNode, status?: React.ReactNode) => (
|
|
<Block icon={FolderRoot} title="Root location" hint="Where the fofos site is on this machine." status={status}>
|
|
{children}
|
|
</Block>
|
|
);
|
|
|
|
if (forbidden) {
|
|
return shell(<p className="text-xs text-muted-foreground">Only the owner can change where the site lives.</p>);
|
|
}
|
|
|
|
if (!saved) {
|
|
return shell(<p className="text-xs text-muted-foreground">Loading…</p>);
|
|
}
|
|
|
|
const dirty = value.trim() !== saved.rootPath;
|
|
|
|
const stopEditing = () => {
|
|
// Back to what is stored, so leaving the mode cannot leave a half-typed path on screen looking
|
|
// like the setting.
|
|
setValue(saved.rootPath);
|
|
setEditing(false);
|
|
setPicking(false);
|
|
setError(null);
|
|
};
|
|
|
|
return shell(
|
|
<div className="flex flex-col gap-3">
|
|
{editing ? (
|
|
<Input
|
|
autoFocus
|
|
value={value}
|
|
onChange={(event) => setValue(event.target.value)}
|
|
placeholder="/home/you/fofos"
|
|
className="w-full font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
) : (
|
|
// `break-all`, not `truncate`. An absolute path is read from BOTH ends — the machine at the
|
|
// front and the folder at the back — so hiding either one is worse than wrapping.
|
|
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
|
|
{saved.rootPath}
|
|
</p>
|
|
)}
|
|
|
|
{editing && picking && (
|
|
<DirectoryPicker
|
|
onCancel={() => setPicking(false)}
|
|
onPick={(absolutePath) => {
|
|
setValue(absolutePath);
|
|
setPicking(false);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{/* Only the state worth knowing. Once it is set, this line has nothing to say and goes. */}
|
|
{!saved.isSet && (
|
|
<p className="text-xs text-muted-foreground/80">Not set yet — this is the default until you save it.</p>
|
|
)}
|
|
|
|
{/* The checkout that lives under that path. Same section, because the path and the thing at the
|
|
path are one question — split apart, somebody could change the root here and read a status
|
|
about the old one there, with both looking equally current. Hidden while editing: it
|
|
describes what is SAVED, and a half-typed path has nothing at it. */}
|
|
{!editing && <WebsiteCheckout rootPath={saved.rootPath} />}
|
|
|
|
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
|
|
gets the full width. */}
|
|
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
|
{editing ? (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
|
|
{picking ? 'Close browser' : 'Browse'}
|
|
</Button>
|
|
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty || !value.trim()}>
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
|
Save
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={stopEditing} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
|
<Pencil className="size-3.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
flashing ? (
|
|
<span className="flex items-center gap-1 text-xs text-success">
|
|
<Check className="size-3.5" />
|
|
Saved
|
|
</span>
|
|
) : null,
|
|
);
|
|
};
|