Sync says what it is doing, file by file
The counters said how MUCH and never which file, so a run that was working normally and one that had wedged produced the same screen: `done` stops moving either way. A photograph is around thirty encodes and a video is three plus a poster ladder, and none of that was visible from outside. So the run keeps a transcript and the button opens it. Every rung reports itself by CONSTRUCTION. Ten `variants.push` call sites became one `add`, which is the point — a rung added later is described without anybody remembering to describe it. The slow stages that produce nothing until they finish say so BEFORE they start instead: the HEIC decode, each video rung, the poster frame. Those are the pauses that read as a hang. `?since=<n>` is what keeps it cheap. The transcript is thousands of lines and polls once a second, so sending all of it every tick would be the same megabyte over and over; the client keeps what it has and asks for the rest. No `since` means everything, which is what a modal opened mid-run wants. The transcript lives in the shared slot, not the modal, so closing it does not throw away the run — somebody who looks, closes, and looks again is watching one run rather than two. `startedAt` is what identifies a run, because `n` restarts at 1 and appending across a boundary would interleave two. Polling, not a socket. Each line transfers once and the events are seconds apart, so a second transport to open, authenticate and reconnect would buy nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useFlash } from './useFlash';
|
||||
import { useMediaSync, type LogLine } from './useMediaSync';
|
||||
|
||||
/**
|
||||
* What the sync is doing, line by line, while it does it.
|
||||
*
|
||||
* ── Why a transcript and not a progress bar ──
|
||||
*
|
||||
* The bar answers "how much" and it was the only answer available. It cannot say WHICH file, and the
|
||||
* question people actually have during a long run is "is it stuck?" — which looks identical to "it is
|
||||
* decoding a 40-megapixel HEIC" from outside. A photograph is around thirty encodes and a video is
|
||||
* three plus a poster ladder; none of that was visible, so a run that was working normally and one
|
||||
* that had wedged produced the same screen.
|
||||
*
|
||||
* ── The lines come from the poll, not a socket ──
|
||||
*
|
||||
* The state is already polled once a second while a run is going, and `?since=` means each line is
|
||||
* transferred once. So this is a stream in the only sense that matters here — lines appear as they
|
||||
* happen — without a second transport to open, authenticate and reconnect. Sub-second arrival would
|
||||
* need SSE and would buy nothing: the events themselves are seconds apart.
|
||||
*
|
||||
* ── It is not the only place a failure is recorded ──
|
||||
*
|
||||
* Closing this loses nothing. The transcript lives in the shared slot and the ledger in Administration
|
||||
* outlives the run entirely — this is the live view, not the record.
|
||||
*/
|
||||
|
||||
const COLOURS: Record<LogLine['level'], string> = {
|
||||
info: 'text-muted-foreground',
|
||||
// The busiest level by far, and the least interesting line at a time. Dimmer than `info` so the
|
||||
// structure of a run — its starts and its finishes — stays readable as the rungs scroll past.
|
||||
step: 'text-muted-foreground/60',
|
||||
done: 'text-foreground',
|
||||
warn: 'text-amber-500',
|
||||
error: 'text-destructive',
|
||||
};
|
||||
|
||||
const clock = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
export const SyncModal = ({ open, onClose }: { open: boolean; onClose: () => void }) => {
|
||||
const { log, state, running, percent, error, start, busy } = useMediaSync();
|
||||
const [copied, flashCopied] = useFlash(1500);
|
||||
|
||||
/*
|
||||
Follow the tail, unless somebody has scrolled away from it.
|
||||
|
||||
A transcript that always jumps to the bottom is unreadable while it is being written: the line
|
||||
somebody is reading leaves the screen a second after they find it. So this follows only while the
|
||||
view is already AT the bottom, which is the state it starts in and returns to on its own.
|
||||
*/
|
||||
const box = useRef<HTMLDivElement | null>(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = box.current;
|
||||
if (!node || !following) return;
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}, [log.length, following]);
|
||||
|
||||
// Reset to following whenever it opens, so reopening mid-run lands on the newest line rather than
|
||||
// wherever the last visit left off.
|
||||
useEffect(() => {
|
||||
if (open) setFollowing(true);
|
||||
}, [open]);
|
||||
|
||||
const transcript = log.map((line) => `${clock(line.at)} ${line.file ? `${line.file} — ` : ''}${line.text}`).join('\n');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
{running && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Sync
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{running
|
||||
? state?.total
|
||||
? `${state.done} of ${state.total}${percent === null ? '' : ` · ${percent}%`}`
|
||||
: 'reading originals…'
|
||||
: state?.finishedAt
|
||||
? 'finished'
|
||||
: 'idle'}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{running && (
|
||||
<div className="h-1 w-full shrink-0 overflow-hidden rounded-full bg-muted">
|
||||
{/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse
|
||||
than one that has not started. */}
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={box}
|
||||
// `onScroll` rather than a button: following is a thing somebody stops by scrolling, which is
|
||||
// what they were already doing, and resumes by scrolling back down.
|
||||
onScroll={(event) => {
|
||||
const node = event.currentTarget;
|
||||
setFollowing(node.scrollHeight - node.scrollTop - node.clientHeight < 24);
|
||||
}}
|
||||
className="min-h-[16rem] flex-1 overflow-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-[11px] leading-relaxed"
|
||||
>
|
||||
{log.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
Nothing yet. Press Sync to build web-ready copies of everything in originals.
|
||||
</p>
|
||||
) : (
|
||||
log.map((line) => (
|
||||
<div key={line.n} className={`flex gap-2 ${COLOURS[line.level]}`}>
|
||||
<span className="shrink-0 opacity-50">{clock(line.at)}</span>
|
||||
{/* The file is not repeated on every rung of the same file — the eye follows a column
|
||||
of identical names badly, and the interesting part is what changed. */}
|
||||
{line.file && <span className="min-w-0 shrink-0 max-w-[45%] truncate">{line.file}</span>}
|
||||
<span className="min-w-0 flex-1 break-words">{line.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="shrink-0 text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
disabled={log.length === 0}
|
||||
onClick={() => {
|
||||
void copyToClipboard(transcript);
|
||||
flashCopied();
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
|
||||
{/* Start from in here too. Opening the modal to watch and then having to close it to press
|
||||
the button is the sort of thing that only reads as obvious to whoever built it. */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => void start()}
|
||||
disabled={busy || running}
|
||||
>
|
||||
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{running ? 'Syncing' : 'Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user