diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 526691f..a901f9c 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -9,6 +9,7 @@ import { LayoutPanel } from './sections/LayoutPanel'; import { PagesPanel } from './sections/PagesPanel'; import { PageBuilderPanel } from './sections/PageBuilderPanel'; import { PublishButton } from './PublishButton'; +import { SyncButton } from './SyncButton'; import { SaveTargetProvider, useSaveButton } from './SaveTarget'; import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode'; import { MessagesPanel } from './MessagesPanel'; @@ -217,9 +218,14 @@ export const DetailPanel = () => {
- {/* Left of Publish, and in that order for a reason: save what you wrote, then make it - public. A row that put them the other way round would read as the sequence backwards. */} + {/* The order is the sequence: save what you wrote, make the media web-ready, then make + it public. A row that put them any other way round would read as the steps backwards. + + Sync sits left of Build for the same reason Build sits left of Publish — it is what + has to have happened first, and a stale `derived/` is a picture missing from the page + you just built. */} +
diff --git a/web/MediaSync.tsx b/web/MediaSync.tsx index 4d55342..a58dbe0 100644 --- a/web/MediaSync.tsx +++ b/web/MediaSync.tsx @@ -1,30 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { CircleAlert, Loader2, RefreshCw } from 'lucide-react'; -import { useClient } from 'hooks/useClient'; -import { Button } from '@/components/ui/button'; -import { readableError } from './errors'; +import { useEffect } from 'react'; +import { CircleAlert } from 'lucide-react'; +import { useMediaSync } from './useMediaSync'; /** - * Building `derived/` from `originals/`, shown where those two folders are. + * What syncing `derived/` is DOING, shown where the two folders are. * - * ── Why it is not its own section ── + * ── The button is not here any more ── * - * It was, briefly. But a section called "Derived media" sitting beside the block that lists - * `originals/` and `derived/` is the same subject described twice, and the reader has to work out that - * the two are about one thing. The action belongs to the folders it acts on, so it lives under them. + * It moved to the panel's bar, beside Build and Publish — see SyncButton for why. What stayed is + * everything that needs room to read: how far a run has got, which files failed, and the history. * - * ── It only appears once there is somewhere to put the output ── - * - * No `media/`, or a partial one, means the button would fail on a missing directory. The Create button - * above it is the thing to press first, and offering both at once is offering a choice where there is - * an order. - * - * ── It polls, and only while something is running ── - * - * A full pass takes minutes, so the request that starts it returns immediately and this asks how it is - * going. The interval is torn down when the answer says it has stopped, so an idle panel makes no - * requests — which is what keeps a screen somebody left open from being a request a second all - * afternoon. + * Both read one `useGlobal` slot, so the button's label and this card cannot disagree about what is + * true. Nothing here fetches: the bar is mounted whenever the plugin is open and does the asking. * * ── Built and skipped are counted separately ── * @@ -38,46 +25,6 @@ import { readableError } from './errors'; * anybody has to act on. */ -type SyncError = { file: string; message: string }; - -type SyncState = { - running: boolean; - total: number | null; - done: number; - built: number; - skipped: number; - /** Ladders renamed into place because their original moved, rather than rebuilt. */ - moved: number; - /** Derived folders removed because their original is gone. Whole-tree syncs only. */ - pruned: number; - errors: SyncError[]; - current: string[]; - startedAt: string | null; - finishedAt: string | null; - fatal: string | null; -}; - -type Run = { - startedAt: string; - finishedAt: string; - trigger: 'manual' | 'watcher'; - scope: string; - total: number; - built: number; - skipped: number; - moved: number; - pruned: number; - errors: SyncError[]; - fatal: string | null; -}; - -type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] }; - -type Ledger = { runs: Run[]; reconciliation: Reconciliation }; - -/** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */ -const POLL_MS = 1000; - /** Recent runs worth showing. Enough to see a pattern, few enough to stay a footnote. */ const SHOWN_RUNS = 5; @@ -93,122 +40,43 @@ const took = (from: string, to: string) => { }; export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => { - const client = useClient(); - const clientRef = useRef(client); - clientRef.current = client; + const { state, ledger, running, percent, outstanding, known, error } = useMediaSync(); - const [state, setState] = useState(null); - const [ledger, setLedger] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - try { - setState(await clientRef.current.get('/fofos-admin/_config/media/sync')); - } catch { - // Owner-only. Root Location above has already said so once if that is the reason. - setState(null); - } - }, []); - - /* - The ledger is NOT polled. - - It walks the whole tree reading manifests, so asking for it once a second beside a progress bar - would have every tick re-read the library. It is read on arrival and again when a run stops, which - are the only two moments its answer can have changed. - */ - const loadLedger = useCallback(async () => { - try { - setLedger(await clientRef.current.get('/fofos-admin/_config/media/ledger')); - } catch { - setLedger(null); - } - }, []); - - useEffect(() => { - void load(); - void loadLedger(); - }, [load, loadLedger]); - - useEffect(() => { - if (!state?.running) return; - const timer = setInterval(() => void load(), POLL_MS); - return () => clearInterval(timer); - }, [state?.running, load]); - - // Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what - // is left to do without the panel being reopened. - const wasRunning = useRef(false); - useEffect(() => { - const running = state?.running ?? false; - if (wasRunning.current && !running) void loadLedger(); - wasRunning.current = running; - }, [state?.running, loadLedger]); - - const start = async () => { - setBusy(true); - setError(null); - try { - const result = await clientRef.current.post<{ started: boolean; state: SyncState }>( - '/fofos-admin/_config/media/sync', - {}, - ); - setState(result.state); - } catch (caught) { - setError(readableError(caught)); - } finally { - setBusy(false); - } - }; - - const running = state?.running ?? false; - const total = state?.total ?? null; - const done = state?.done ?? 0; - // Null total means the walk has not finished, so there is nothing honest to divide by yet. - const percent = total && total > 0 ? Math.round((done / total) * 100) : null; 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. + Reported rather than lifted, because the parent needs the single fact it cannot compute — whether + this part of the setup is finished — and nothing else about the sync belongs to it. */ useEffect(() => { - if (reconciliation) onSettled?.(outstanding === 0); - }, [reconciliation, outstanding, onSettled]); + if (known) onSettled?.(outstanding === 0); + }, [known, outstanding, onSettled]); return (
-
- {/* Not what the last run did — what is TRUE NOW. A run's counts age the moment somebody uploads - a file, and "62 built" three days ago says nothing about whether derived/ is current. This is - recomputed from the filesystem, using the same freshness test the sync itself uses. */} -

- {running - ? total === null - ? 'Reading originals…' - : `${done} of ${total}${state?.current.length ? ` · ${state.current.join(', ')}` : ''}` - : reconciliation - ? outstanding === 0 - ? `${reconciliation.current} of ${reconciliation.originals} current` - : [ - reconciliation.missing.length ? `${reconciliation.missing.length} missing` : '', - reconciliation.stale.length ? `${reconciliation.stale.length} changed` : '', - ] - .filter(Boolean) - .join(' · ') - : 'Web-ready copies of everything in originals.'} -

+ {/* Not what the last run did — what is TRUE NOW. A run's counts age the moment somebody uploads + a file, and "62 built" three days ago says nothing about whether derived/ is current. This is + recomputed from the filesystem, using the same freshness test the sync itself uses. - -
+ Full width, because the button that used to sit beside it is in the bar now. */} +

+ {running + ? state?.total === null || state?.total === undefined + ? 'Reading originals…' + : `${state.done} of ${state.total}${state.current.length ? ` · ${state.current.join(', ')}` : ''}` + : reconciliation + ? outstanding === 0 + ? `${reconciliation.current} of ${reconciliation.originals} current` + : [ + reconciliation.missing.length ? `${reconciliation.missing.length} missing` : '', + reconciliation.stale.length ? `${reconciliation.stale.length} changed` : '', + ] + .filter(Boolean) + .join(' · ') + : 'Web-ready copies of everything in originals.'} +

{running && (
diff --git a/web/SyncButton.tsx b/web/SyncButton.tsx new file mode 100644 index 0000000..1e5aa49 --- /dev/null +++ b/web/SyncButton.tsx @@ -0,0 +1,66 @@ +import { Loader2, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useMediaSync, useMediaSyncFeed } from './useMediaSync'; +import { useReady } from './useReady'; + +/** + * Sync, in the panel's bar beside Build and Publish. + * + * ── Why it left the card ── + * + * It sat under the two folders it acts on, which was the right place while it was a detail of setting + * fofos up. It is not one: `derived/` being stale is the reason a picture is missing from a page + * somebody is looking at, and the fix was three sections away, in a card about where the site lives. + * Beside Build and Publish it reads as what it is — the third thing you do to a site. + * + * The card keeps everything there is no room for up here: the progress bar, the failures, and the run + * history. Both read one slot, so they cannot disagree about what is true. + * + * ── It owns the fetching ── + * + * `useMediaSyncFeed` has exactly one caller and this is it, because the bar is mounted whenever + * anything in this plugin is open and the Administration card is mounted only sometimes. The thing + * that is always there should be the thing that asks. + * + * ── The label is the state ── + * + * `Sync 3` rather than a badge or a dot: the number IS the reason to press it, and a button that says + * "Sync now" when there is nothing to sync invites a press that does nothing. Idle and current, it + * still says "Sync" — the action stays available, because a file changed outside the watcher's notice + * is exactly the case somebody needs it for. + */ +export const SyncButton = () => { + const { running, percent, outstanding, busy, start } = useMediaSync(); + const { rootSet } = useReady(); + + useMediaSyncFeed(); + + /* + Gated on the ROOT, not on `ready`. + + Sync reads a folder on this machine and never speaks to fofos, so a broken connection is no reason + to withhold it — that distinction is the one `useReady` records as the bug that made `rootSet` a + separate answer in the first place. + */ + if (!rootSet) return null; + + return ( + + ); +}; diff --git a/web/useMediaSync.ts b/web/useMediaSync.ts new file mode 100644 index 0000000..5710313 --- /dev/null +++ b/web/useMediaSync.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useClient } from 'hooks/useClient'; +import { useGlobal } from 'hooks/useGlobal'; +import { readableError } from './errors'; + +/** + * The state of `derived/` — what is running, what is outstanding, and how to start a pass. + * + * ── Why this is shared at all ── + * + * The button moved to the panel's action bar, beside Build and Publish, because syncing is one of the + * things somebody does to a site rather than a detail of one card. The rest of it — the progress bar, + * the failures, the run history — stayed in Administration, where there is room to read it. + * + * So one subject is drawn in two places at once, and they must not disagree. A header saying "Sync + * now" beside a card saying "3 changed" is two components each deciding what is true. + * + * ── `useGlobal`, not a context ── + * + * A slot in the app-wide query cache, keyed by name: whoever reads the key gets the value, whoever + * writes it re-renders every reader. No provider, so nothing has to wrap the bar and the panel + * together, and a third place that wants this later just reads the key. + * + * ── One reader FETCHES, everyone else reads ── + * + * `useGlobal` shares the value, not the work: mount the same effects twice and the endpoint is polled + * twice a second. So the fetching is a separate hook with exactly one caller — `SyncButton`, which + * lives in the bar and is therefore mounted whenever anything in this plugin is open. The + * Administration card is mounted only sometimes, which is precisely why it must not be the one asking. + */ + +export type SyncError = { file: string; message: string }; + +export type SyncState = { + running: boolean; + total: number | null; + done: number; + built: number; + skipped: number; + /** Ladders renamed into place because their original moved, rather than rebuilt. */ + moved: number; + /** Derived folders removed because their original is gone. Whole-tree syncs only. */ + pruned: number; + errors: SyncError[]; + current: string[]; + startedAt: string | null; + finishedAt: string | null; + fatal: string | null; +}; + +export type Run = { + startedAt: string; + finishedAt: string; + trigger: 'manual' | 'watcher'; + scope: string; + total: number; + built: number; + skipped: number; + moved: number; + pruned: number; + errors: SyncError[]; + fatal: string | null; +}; + +export type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] }; + +export type Ledger = { runs: Run[]; reconciliation: Reconciliation }; + +/** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */ +const POLL_MS = 1000; + +/** The slot. One name, so a third reader is a one-line addition rather than a refactor. */ +const KEY = ['fofos-admin', 'media-sync']; + +type Shared = { + state: SyncState | null; + ledger: Ledger | null; + /** A start request is in flight — distinct from `state.running`, which is the SERVER's answer. */ + busy: boolean; + error: string | null; +}; + +const EMPTY: Shared = { state: null, ledger: null, busy: false, error: null }; + +export type MediaSync = Shared & { + /** A pass is under way. */ + running: boolean; + /** How far through, or null while the walk is still counting — nothing honest to divide by yet. */ + percent: number | null; + /** Files with no ladder plus files whose ladder is stale. Zero means `derived/` is current. */ + outstanding: number; + /** Whether the ledger has been read at all. Before that `outstanding` is 0 because it is unknown. */ + known: boolean; + start: () => Promise; +}; + +/** Read the sync, and start one. Any number of callers. */ +export const useMediaSync = (): MediaSync => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [shared, setShared] = useGlobal(KEY, EMPTY); + + const start = useCallback(async () => { + setShared((was) => ({ ...was, busy: true, error: null })); + try { + const result = await clientRef.current.post<{ started: boolean; state: SyncState }>( + '/fofos-admin/_config/media/sync', + {}, + ); + setShared((was) => ({ ...was, state: result.state, busy: false })); + } catch (caught) { + setShared((was) => ({ ...was, busy: false, error: readableError(caught) })); + } + // `setShared` is rebuilt every render by useGlobal — see the note in SitePreview about verbs from + // useClient. Left out of the deps on purpose, and it closes over nothing that changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const total = shared.state?.total ?? null; + const done = shared.state?.done ?? 0; + const reconciliation = shared.ledger?.reconciliation ?? null; + + return { + ...shared, + running: shared.state?.running ?? false, + percent: total && total > 0 ? Math.round((done / total) * 100) : null, + outstanding: (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0), + known: reconciliation !== null, + start, + }; +}; + +/** + * Keep the slot current. EXACTLY ONE caller — `SyncButton`. + * + * Separate from reading because `useGlobal` shares the value and not the work: two components running + * these effects would poll the same endpoint twice a second and re-read the ledger twice per run. + */ +export const useMediaSyncFeed = (): void => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [shared, setShared] = useGlobal(KEY, EMPTY); + const setRef = useRef(setShared); + setRef.current = setShared; + + const load = useCallback(async () => { + try { + const state = await clientRef.current.get('/fofos-admin/_config/media/sync'); + setRef.current((was) => ({ ...was, state })); + } catch { + // Owner-only. Root Location has already said so once if that is the reason. + setRef.current((was) => ({ ...was, state: null })); + } + }, []); + + /* + The ledger is NOT polled. + + It walks the whole tree reading manifests, so asking for it once a second beside a progress bar + would have every tick re-read the library. It is read on arrival and again when a run stops, which + are the only two moments its answer can have changed. + */ + const loadLedger = useCallback(async () => { + try { + const ledger = await clientRef.current.get('/fofos-admin/_config/media/ledger'); + setRef.current((was) => ({ ...was, ledger })); + } catch { + setRef.current((was) => ({ ...was, ledger: null })); + } + }, []); + + useEffect(() => { + void load(); + void loadLedger(); + }, [load, loadLedger]); + + const running = shared.state?.running ?? false; + + useEffect(() => { + if (!running) return; + const timer = setInterval(() => void load(), POLL_MS); + return () => clearInterval(timer); + }, [running, load]); + + // Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what + // is left to do without the panel being reopened. + const wasRunning = useRef(false); + useEffect(() => { + if (wasRunning.current && !running) void loadLedger(); + wasRunning.current = running; + }, [running, loadLedger]); +};