storebound/apps/desktop/src/ui/OwnerAction.tsx

144 lines
8.1 KiB
TypeScript

// An owner action with its plan in the window first: the button runs the dry run (the host forces dryRun for every
// call from here), this panel shows the plan table and what blocks it, then "Continue" goes through
// write_with_confirm: the host runs the dry run again, shows it in a native dialog and mints the confirm token only on
// the dialog's own button. While the real run goes (a submit retries up to 10 minutes while Apple says "not ready
// yet"), its progress lines stream into the panel; Cancel stops the command. Used for submit for review, resubmit,
// in-app purchases to review, the Play production release and price changes of existing products.
import { failureText } from '../lib/results';
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
import { planBlockers } from '../lib/submit';
import { useData } from '../store';
import { Icon, type IconName } from './icons';
import { Notice } from './kit';
type Stage =
| { s: 'idle' }
| { s: 'dry' }
| { s: 'plan'; dry: ToolResult; note?: string }
| { s: 'running'; dry: ToolResult; job: string; lines: string[] }
| { s: 'done'; dry: ToolResult; ok: boolean; declined: boolean; lines: string[]; detail: string };
/** Exit code of a command that stopped because the owner's answer was missing (lib/ctx declined()). */
export const DECLINED_EXIT = 3;
const clean = (out: string): string => out.split('\n').filter(l => l.trim()).map(l => l.replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '')).join('\n');
const tail = (out: string): string => clean(out).split('\n').at(-1) ?? '';
export interface OwnerActionProps {
project: Project;
method: string;
args: Record<string, unknown>;
/** Button and panel title. */
label: string;
icon: IconName;
/** Toast and panel line when it went through. */
done: string;
/** What happens, in plain words, above the plan. */
lead?: ReactNode;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
hint?: string;
after?: () => void;
}
export function OwnerAction(p: OwnerActionProps) {
const d = useData();
const [st, setSt] = useState<Stage>({ s: 'idle' });
const plan = async () => {
setSt({ s: 'dry' });
try {
const r = await api.call<ToolResult>(p.method, p.project.id, { args: p.args }, newJob(`${p.method}-plan`));
setSt({ s: 'plan', dry: r });
} catch (e) {
setSt({ s: 'idle' });
d.toast({ kind: 'bad', title: t('oa.planFailed', { what: p.label }), detail: errorText(e) });
}
};
const go = async (dry: ToolResult) => {
const job = newJob(p.method);
setSt({ s: 'running', dry, job, lines: [] });
const lines: string[] = [];
const un = onProgress(e => {
if (e.job !== job || !e.line?.trim()) return;
lines.push(e.line);
setSt(x => (x.s === 'running' && x.job === job ? { ...x, lines: lines.slice(-200) } : x));
});
try {
const o = await api.writeWithConfirm(p.method, p.project.id, p.args, job);
if (o.stage === 'dialog') setSt({ s: 'plan', dry: o.dryRun, note: t('oa.notSent') });
else if (o.stage === 'dry-run') setSt({ s: 'plan', dry: o.dryRun, note: t('oa.dryChanged') });
else {
const r = o.result!;
const declined = r.exitCode === DECLINED_EXIT;
setSt({ s: 'done', dry, ok: r.ok, declined, lines: lines.slice(-200), detail: tail(r.output) });
d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: (r.ok || declined ? tail(r.output) : failureText(r)) || undefined });
if (r.ok) p.after?.();
}
} catch (e) {
const msg = errorText(e);
setSt({ s: 'done', dry, ok: false, declined: false, lines: lines.slice(-200), detail: /cancel/i.test(msg) ? t('oa.cancelled') : msg });
} finally { un.then(f => f()); }
};
const busy = st.s === 'dry';
return (
<>
<button className={`btn btn-sm${p.primary ? ' btn-primary' : ''}${p.danger ? ' btn-danger' : ''}`} disabled={p.disabled || busy || st.s === 'running'} aria-busy={busy || undefined} title={p.hint}
onClick={() => { plan(); }}>
<Icon name={busy ? 'refresh' : p.icon} className={busy ? 'spin' : undefined} />{p.label}
</button>
{st.s !== 'idle' && st.s !== 'dry' && (
<PlanPanel st={st} p={p} onGo={() => { go(st.dry); }} onCancel={() => { if (st.s === 'running') api.cancel(st.job).catch(() => {}); }} onClose={() => setSt({ s: 'idle' })} />
)}
</>
);
}
function PlanPanel({ st, p, onGo, onCancel, onClose }: {
st: Extract<Stage, { s: 'plan' | 'running' | 'done' }>; p: OwnerActionProps; onGo: () => void; onCancel: () => void; onClose: () => void;
}) {
const root = useRef<HTMLDivElement>(null);
const logRef = useRef<HTMLPreElement>(null);
const running = st.s === 'running';
// focus the panel when it opens and whenever a stage ends (the button that had focus may be gone), so Escape and Tab
// keep working inside it
useEffect(() => { if (!running && !root.current?.contains(document.activeElement)) root.current?.focus(); }, [st.s, running]);
const lines = st.s === 'running' || st.s === 'done' ? st.lines : [];
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [lines.length]);
const blockers = planBlockers(st.dry.data);
const dryOk = st.dry.ok && !blockers.length;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !running) { e.preventDefault(); onClose(); } };
return (
<div className="scrim lightbox" onMouseDown={e => { if (e.target === e.currentTarget && !running) onClose(); }}>
<div className="lb cf-panel oa-panel" role="dialog" aria-modal="true" aria-labelledby="oa-title" tabIndex={-1} ref={root} onKeyDown={onKey}>
<div className="cf-body stack">
<div className="stack" style={{ gap: 4 }}>
<h2 id="oa-title" className="row" style={{ gap: 8 }}><Icon name={p.icon} />{p.label}</h2>
<code className="small muted selectable">{st.dry.command}</code>
</div>
{p.lead && <Notice tone="info" icon="shield">{p.lead}</Notice>}
{st.s === 'plan' && st.note && <Notice tone="info">{st.note}</Notice>}
{!st.dry.ok && <Notice tone="bad" icon="alertCircle">{t('oa.dryBad')}{tail(st.dry.output) ? `: ${tail(st.dry.output)}` : ''}</Notice>}
{blockers.map((b, i) => <Notice key={i} tone="bad" icon="alertCircle">{b}</Notice>)}
<div className="stack" style={{ gap: 6 }}>
<b className="small">{t('oa.plan')}</b>
<pre className="log selectable plan-table" aria-label={t('oa.plan')}>{clean(st.dry.output) || t('oa.noOutput')}</pre>
</div>
{running && <Notice tone="info" icon="clock">{t('oa.running')}</Notice>}
{st.s === 'done' && (st.ok ? <Notice tone="ok" icon="check">{p.done}{st.detail ? `: ${st.detail}` : ''}</Notice>
: st.declined ? <Notice tone="warn" icon="alert">{t('write.declined')}{st.detail ? `: ${st.detail}` : ''}</Notice>
: <Notice tone="bad" icon="alertCircle">{t('write.failed')}{st.detail ? `: ${st.detail}` : ''}</Notice>)}
{lines.length > 0 && <pre className="log" ref={logRef} aria-live="polite" aria-label={t('oa.progress')}>{lines.map(l => l.replace(/^\d\d:\d\d:\d\d /, '')).join('\n')}</pre>}
</div>
<div className="lb-foot">
<div className="grow small muted">{st.s === 'plan' ? (dryOk ? t('oa.foot') : t('oa.fixFirst')) : null}</div>
{running ? <button className="btn btn-sm btn-danger" onClick={onCancel}><Icon name="x" />{t('oa.stop')}</button>
: <button className="btn btn-sm btn-ghost" onClick={onClose}>{st.s === 'done' ? t('common.close') : t('common.cancel')}</button>}
{st.s === 'plan' && <button className={`btn btn-sm ${p.danger ? 'btn-danger' : 'btn-primary'}`} disabled={!dryOk} onClick={onGo}><Icon name={p.icon} />{t('oa.continue', { what: p.label })}</button>}
{running && <span className="small muted row" style={{ gap: 6 }}><Icon name="refresh" className="spin ic13" />{t('oa.working')}</span>}
</div>
</div>
</div>
);
}