Sync moves to the bar, beside Build and Publish

It sat under the two folders it acts on, which was right while it was a detail of setting fofos up. It
is not one: a stale `derived/` is why 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, and the one that has to happen first.

The card keeps what needs room: the progress bar, the failures, the run history.

Shared through `useGlobal`, keyed `['fofos-admin','media-sync']` — a slot in the app-wide query cache,
so no provider has to wrap the bar and the panel together and a third reader is one line. Not a
context: this codebase has one for Save because a SECTION owns that state and announces it upward.
Nobody owns this one; it is a fact about a folder, which is what a keyed slot is for.

`useGlobal` shares the value and not the work, so the fetching is a separate hook with exactly one
caller — the button. The bar is mounted whenever the plugin is open and the Administration card is
mounted only sometimes, so the thing that is always there is the thing that asks. Two callers would
poll the same endpoint twice a second.

Gated on `rootSet`, not `ready`: sync reads a folder on this machine and never speaks to fofos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 11:58:28 +01:00
co-authored by Claude Opus 5
parent 307a7d665a
commit 78db209ea1
4 changed files with 304 additions and 168 deletions
+196
View File
@@ -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<void>;
};
/** 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<Shared>(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<Shared>(KEY, EMPTY);
const setRef = useRef(setShared);
setRef.current = setShared;
const load = useCallback(async () => {
try {
const state = await clientRef.current.get<SyncState>('/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<Ledger>('/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]);
};