From cc283ee7b645a42b57461a433f0e0da277c3ceb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 11:52:54 +0100 Subject: [PATCH] Root location folds away once there is nothing left to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/Block.tsx | 57 ++++++++++++++++++++++++++++++++++----- web/MediaFolder.tsx | 17 ++++++++++-- web/MediaSync.tsx | 13 ++++++++- web/RootLocationBlock.tsx | 52 ++++++++++++++++++++++++++++++++--- web/WebsiteCheckout.tsx | 14 +++++++++- 5 files changed, 139 insertions(+), 14 deletions(-) diff --git a/web/Block.tsx b/web/Block.tsx index 78cc1d0..e6fe42b 100644 --- a/web/Block.tsx +++ b/web/Block.tsx @@ -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; -}) => ( -
-
+}) => { + const shut = collapsible && collapsed; + + const header = ( +

{title} + {collapsible && ( + + )}

+ {/* 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. */}

{hint}

{/* 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 &&
{status}
}
- {children} -
-); + ); + + return ( +
+ {collapsible ? ( + + ) : ( + header + )} + {!shut && children} +
+ ); +}; diff --git a/web/MediaFolder.tsx b/web/MediaFolder.tsx index a8af961..a62fe41 100644 --- a/web/MediaFolder.tsx +++ b/web/MediaFolder.tsx @@ -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(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(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 && } + {status.complete && } ); }; diff --git a/web/MediaSync.tsx b/web/MediaSync.tsx index 70b5bf5..173d9aa 100644 --- a/web/MediaSync.tsx +++ b/web/MediaSync.tsx @@ -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 (
diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx index e869ee8..2ce739b 100644 --- a/web/RootLocationBlock.tsx +++ b/web/RootLocationBlock.tsx @@ -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(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('/fofos-admin/_config/root'); @@ -99,7 +129,17 @@ export const RootLocationBlock = () => { }; const shell = (children: React.ReactNode, status?: React.ReactNode) => ( - + {children} ); @@ -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 && } + {!editing && } {/* 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 && } + {!editing && } {/* 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 = () => { Saved + ) : 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. + ) : null, ); }; diff --git a/web/WebsiteCheckout.tsx b/web/WebsiteCheckout.tsx index f792fd2..efcbba3 100644 --- a/web/WebsiteCheckout.tsx +++ b/web/WebsiteCheckout.tsx @@ -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);