Files
fofos-admin/web/RootLocationBlock.tsx
T
pastilhasandClaude Opus 5 e621bc761d the block knows it is settled before it paints, so it stops flickering
It could not. Each part of Root location fetches its own status, so the block
had no way to know whether it was finished until its children had mounted and
reported — meaning it painted open and then folded away, on every load. That is
not a rendering problem to tune: the information genuinely was not there yet.

So one request answers the three bits first, and nothing renders until both it
and the path have arrived. The children still own their detail and still report
changes upward while the block is open; the summary only seeds the opening
state. Two sources for one fact with a clean division — the summary is what is
true on ARRIVAL, the callbacks are what changes while somebody is looking.

`settledNow` picks between them: the summary while nothing has reported, the
live value once anything has. Using the live value throughout would open the
block for exactly the gap between the summary landing and the children
answering, which is the flicker with extra steps.

The summary walks the media tree, so it is deliberately not polled, and it skips
the walk entirely when there is no media folder to walk.

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

296 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleCheck, 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';
// Shared, since kanban wanted the same picker — see the note in its own file.
import { DirectoryPicker } from 'officerdev';
import { Block } from './Block';
import { WebsiteCheckout } from './WebsiteCheckout';
import { MediaFolder } from './MediaFolder';
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();
/*
Finished, and therefore worth folding away.
Three bits reported up 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 here — each child fetches its
own status, and lifting all of it into this component to answer one question would be a much worse
trade than three booleans.
*/
const [websiteReady, setWebsiteReady] = useState(false);
const [mediaReady, setMediaReady] = useState(false);
const settled = Boolean(saved?.isSet) && websiteReady && mediaReady && !editing;
/*
The same three bits, answered in ONE request before anything renders.
Without it the block cannot know whether it is finished until its children have mounted and
reported — so it paints open and then folds away, on every single load. That flicker is not a
rendering problem to tune; the information genuinely was not there yet.
So this seeds the opening state, and the callbacks above keep it live once the block is open. Two
sources for one fact, with a clear division: the summary is what is true on ARRIVAL, the callbacks
are what changes while somebody is looking.
*/
const [summary, setSummary] = useState<{ rootSet: boolean; websiteReady: boolean; mediaReady: boolean } | null>(
null,
);
useEffect(() => {
void (async () => {
try {
setSummary(await clientRef.current.get<typeof summary & object>('/fofos-admin/_config/summary'));
} catch {
// A member, or the route failing. Neither is a reason to hold the block hostage — fall back to
// the old behaviour of opening and letting the children answer.
setSummary({ rootSet: false, websiteReady: false, mediaReady: false });
}
})();
}, []);
/*
Shut on the first arrival at a settled block, and open the moment anything needs attention.
`null` means nobody has decided yet, which is what lets a deliberate expand SURVIVE — once somebody
has opened it, `collapsed` is false and this effect will not run again while it stays settled. A
plain `useState(true)` would slam it shut on every re-render that touched the dependency.
*/
const [collapsed, setCollapsed] = useState<boolean | null>(null);
// Seeded from the summary, so the FIRST paint is already right. `null` stays the sentinel for
// "nobody has decided", which is what lets a deliberate expand survive everything below.
useEffect(() => {
if (summary) setCollapsed((chosen) => (chosen === null ? summary.mediaReady && summary.websiteReady && summary.rootSet : chosen));
}, [summary]);
useEffect(() => {
setCollapsed((chosen) => {
// Not settled: force it open if it was shut, but LEAVE `null` alone. Writing false here was a
// bug — `settled` is false on the first render, so the sentinel was spent before it could mean
// anything and the block never folded itself away.
if (!settled) return chosen === null ? null : false;
return chosen === null ? true : chosen;
});
}, [settled]);
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);
}
};
/*
Settled, according to whichever source has spoken.
The summary answers on arrival; the children answer from then on. Taking the summary while nothing
has reported yet, and the live value once anything has, is what makes the first paint correct
without freezing the block on a snapshot.
*/
const reported = websiteReady || mediaReady;
const settledNow = editing ? false : reported ? settled : Boolean(summary?.rootSet && summary.websiteReady && summary.mediaReady);
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}
// Only offered once there is nothing to do. A block you can shut on unfinished setup is a way to
// hide the thing you came here to fix.
// `settledNow` rather than `settled`: between the summary arriving and the children reporting,
// no child has answered yet, and using the live value would open the block for exactly that gap.
collapsible={settledNow}
collapsed={settledNow && collapsed !== false}
onCollapsedChange={setCollapsed}
>
{children}
</Block>
);
if (forbidden) {
return shell(<p className="text-xs text-muted-foreground">Only the owner can change where the site lives.</p>);
}
// Both, not either. Rendering on the path alone puts the block on screen before it knows whether it
// is finished, which is the flicker this was all for.
if (!saved || !summary) {
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} onSettled={setWebsiteReady} />}
{/* Beside the checkout, under the same root — a backup of the root is then the whole site, the
code and the things it serves. Hidden while editing for the same reason as the checkout. */}
{!editing && <MediaFolder rootPath={saved.rootPath} onSettled={setMediaReady} />}
{/* 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>
) : settledNow ? (
// Big, and the only thing in the row. Everything under here is in order, and a shut block whose
// whole message is "nothing to do" should say it in one glance rather than in a sentence.
<CircleCheck className="size-6 text-success" />
) : null,
);
};