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
+8 -2
View File
@@ -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 = () => {
</div>
<div className="flex flex-1 items-center justify-end gap-2">
{/* 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. */}
<SaveButton />
<SyncButton />
<PublishButton />
</div>
</div>
+34 -166
View File
@@ -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<SyncState | null>(null);
const [ledger, setLedger] = useState<Ledger | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setState(await clientRef.current.get<SyncState>('/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<Ledger>('/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 (
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between gap-3">
{/* 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. */}
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{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.'}
</p>
{/* 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.
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void start()} disabled={busy || running}>
{running ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync now'}
</Button>
</div>
Full width, because the button that used to sit beside it is in the bar now. */}
<p className="min-w-0 truncate text-xs text-muted-foreground">
{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.'}
</p>
{running && (
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
+66
View File
@@ -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 (
<Button
size="sm"
variant="outline"
className="h-6 shrink-0 gap-1.5 px-2 text-xs"
onClick={() => void start()}
disabled={busy || running}
title={
running
? 'Building web-ready copies of everything in originals'
: outstanding > 0
? `${outstanding} file${outstanding === 1 ? '' : 's'} in originals have no current copy in derived`
: 'Rebuild anything in derived that is missing or out of date'
}
>
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync'}
</Button>
);
};
+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]);
};