Root location folds away once there is nothing left to do

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>
This commit is contained in:
2026-08-29 11:52:54 +01:00
co-authored by Claude Opus 5
parent f30d40446a
commit cc283ee7b6
5 changed files with 139 additions and 14 deletions
+51 -6
View File
@@ -1,4 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import { ChevronDown } from 'lucide-react';
/**
* One titled block of Administration.
@@ -15,12 +16,26 @@ import type { LucideIcon } from 'lucide-react';
* Each block renders its OWN Block, rather than the panel wrapping them. The status belongs to the
* setting that produced it, and lifting that state up to the panel would put one block's timer in a
* component that has nothing to do with it.
*
* ── Collapsing is CONTROLLED, and that is the whole design ──
*
* This component does not decide when a block is finished — it cannot, because "finished" is different
* for every setting and only the block itself knows. So the owner passes `collapsed` and gets told
* about a click, and what remains here is the chrome: a header that is a button, and children that are
* not rendered while it is shut.
*
* NOT RENDERED rather than hidden. A collapsed block's children keep polling if they are merely
* display:none, which is how a settled panel goes on making requests all afternoon about something
* nobody is looking at.
*/
export const Block = ({
icon: Icon,
title,
hint,
status,
collapsible = false,
collapsed = false,
onCollapsedChange,
children,
}: {
icon: LucideIcon;
@@ -28,21 +43,51 @@ export const Block = ({
hint: string;
/** Top-right. Usually ephemeral — see `useFlash`. */
status?: React.ReactNode;
/** The header becomes a button. The owner decides when this is worth offering. */
collapsible?: boolean;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
children?: React.ReactNode;
}) => (
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-3">
}) => {
const shut = collapsible && collapsed;
const header = (
<div className="flex items-start justify-between gap-3 text-left">
<div className="min-w-0 space-y-1">
<h3 className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4" />
{title}
{collapsible && (
<ChevronDown
className={`size-3.5 text-muted-foreground transition-transform ${shut ? '-rotate-90' : ''}`}
/>
)}
</h3>
{/* The hint is what the block is FOR, so it stays whether or not the block is open — a shut
row with only a title says nothing to somebody who has not opened it before. */}
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
{/* Reserves nothing when empty. The status is short and the title is not, so there is no reflow
to guard against — and an always-present placeholder would read as a field. */}
{status && <div className="shrink-0">{status}</div>}
</div>
{children}
</section>
);
);
return (
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
{collapsible ? (
<button
type="button"
onClick={() => onCollapsedChange?.(!collapsed)}
aria-expanded={!shut}
className="-m-1 cursor-pointer rounded p-1 hover:bg-muted/40"
>
{header}
</button>
) : (
header
)}
{!shut && children}
</section>
);
};
+15 -2
View File
@@ -31,7 +31,13 @@ type Status = { path: string; present: boolean; blocked: boolean; children: Chil
const count = (entries: number | null) =>
entries === null ? '' : entries === 0 ? 'empty' : `${entries} item${entries === 1 ? '' : 's'}`;
export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
export const MediaFolder = ({
rootPath,
onSettled,
}: {
rootPath: string;
onSettled?: (settled: boolean) => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
@@ -39,6 +45,9 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
const [status, setStatus] = useState<Status | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/* The folders existing is only half of finished; the other half is `derived/` being current, which
only `MediaSync` knows. Both have to be true before this reports settled upward. */
const [synced, setSynced] = useState(false);
const load = useCallback(async () => {
try {
@@ -55,6 +64,10 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
void load();
}, [load, rootPath]);
useEffect(() => {
onSettled?.(Boolean(status?.complete) && synced);
}, [status?.complete, synced, onSettled]);
const create = async () => {
setBusy(true);
setError(null);
@@ -126,7 +139,7 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
{/* Only once both folders exist. Until then the button would be asking to write into a directory
that is not there, and Create above is the thing to press. */}
{status.complete && <MediaSync />}
{status.complete && <MediaSync onSettled={setSynced} />}
</div>
);
};
+12 -1
View File
@@ -86,7 +86,7 @@ const took = (from: string, to: string) => {
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
export const MediaSync = () => {
export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
@@ -164,6 +164,17 @@ export const MediaSync = () => {
const reconciliation = ledger?.reconciliation ?? null;
const outstanding = (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0);
/*
One bit, upward: is there anything left to do?
Reported rather than lifted, because everything else about this — the polling, the run history, the
errors — belongs here and would only be harder to read in a parent. What the parent needs is the
single fact it cannot compute: whether this part of the setup is finished.
*/
useEffect(() => {
if (reconciliation) onSettled?.(outstanding === 0);
}, [reconciliation, outstanding, onSettled]);
return (
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between gap-3">
+48 -4
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, FolderRoot, Loader2, Pencil } from 'lucide-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';
@@ -63,6 +63,36 @@ export const RootLocationBlock = () => {
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;
/*
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);
useEffect(() => {
setCollapsed((chosen) => {
// Not settled yet: force it open if somebody had shut it, 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');
@@ -99,7 +129,17 @@ export const RootLocationBlock = () => {
};
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}>
<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.
collapsible={settled}
collapsed={settled && collapsed !== false}
onCollapsedChange={setCollapsed}
>
{children}
</Block>
);
@@ -164,11 +204,11 @@ export const RootLocationBlock = () => {
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} />}
{!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} />}
{!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. */}
@@ -199,6 +239,10 @@ export const RootLocationBlock = () => {
<Check className="size-3.5" />
Saved
</span>
) : settled ? (
// 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,
);
};
+13 -1
View File
@@ -52,7 +52,13 @@ type Status = {
type RunResult = { ok: boolean; output: string; status: Status };
export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
export const WebsiteCheckout = ({
rootPath,
onSettled,
}: {
rootPath: string;
onSettled?: (settled: boolean) => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
@@ -80,6 +86,12 @@ export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
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);