144 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|