one spinner per action, one button for both, and the shared console

Four things, all of them the panel telling a small lie.

A single `busy` boolean put BOTH rows into a spinner while only the pressed one
was doing anything — the other looked started, then finished without having done
a thing. It is now which action is running, so a row spins when it is the one
running or when both are.

Pushing both is the common case and was two clicks. It is one, running
production then development sequentially: they run the same drizzle against one
Postgres, and two at once buys nothing but an interleaved transcript.

The status is re-read after every push regardless of outcome. It was taken from
the route's reply, which only exists on the path where the push SUCCEEDED — a
failure answers 400 and throws, and a push that failed halfway can still have
changed the table count. That is the difference between the panel being right
and the panel being right when nothing went wrong.

And the output is the same console the checkout uses, extracted rather than
copied: black, copyable, gone a few seconds after a success and never after a
failure. WebsiteCheckout keeps its behaviour and loses the state that used to
implement it.

Restore stays disabled. What a backup IS here is still undecided, and a button
that guesses at the source of a restore does not belong near a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:47:58 +01:00
co-authored by Claude Opus 5
parent f7df5f8b10
commit bda59bdf03
3 changed files with 144 additions and 57 deletions
+5 -34
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleAlert, CircleCheck, CircleDashed, Copy, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
import { CircleAlert, CircleCheck, CircleDashed, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
import { useFlash } from './useFlash';
import { Console } from './Console';
/**
* The website checkout under the root location: is it there, clone it, update it.
@@ -67,8 +66,6 @@ export const WebsiteCheckout = ({
const [busy, setBusy] = useState<'clone' | 'update' | null>(null);
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [showing, show] = useFlash();
const [copied, flashCopied] = useFlash(1500);
const load = useCallback(async () => {
try {
@@ -101,9 +98,6 @@ export const WebsiteCheckout = ({
setStatus(result.status);
setOutput(result.output || null);
setFailed(!result.ok);
// Only on the way out. A failure has no timer at all rather than a long one — nothing should be
// able to take it off the screen except the next attempt.
if (result.ok) show();
} catch (failure) {
// A 400 carries git's own words in the body; `readableError` surfaces them.
setOutput(readableError(failure));
@@ -165,32 +159,9 @@ export const WebsiteCheckout = ({
</div>
</div>
{/* git's own words, in git's own shape — a console, so it looks like the thing it is quoting
rather than like a paragraph of the form. Scrolled, not truncated: the useful line of a
failure is usually the last one. */}
{output && (failed || showing) && (
<div className="relative">
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 pr-10 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
{/* Pinned to the corner of the BOX, not scrolled with the text — a copy button that leaves
the viewport when the transcript is long is missing exactly when the transcript is worth
copying. `pr-10` above keeps the text out from under it. Its own dark background because
it sits over the output rather than beside it. */}
<button
type="button"
onClick={async () => {
if (await copyToClipboard(output)) flashCopied();
}}
aria-label="Copy this output"
title="Copy"
className="absolute top-1.5 right-1.5 rounded border border-border/60 bg-black/80 p-1.5 text-neutral-400 transition-colors hover:text-neutral-100"
>
{copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
</button>
</div>
)}
{/* git's own words, in git's own shape — see `Console`, which owns the timing and the copy
button. Shared with the schema pushes in Database, which need exactly the same thing. */}
<Console output={output} failed={failed} />
</div>
);
};