// 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; /** 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({ s: 'idle' }); const plan = async () => { setSt({ s: 'dry' }); try { const r = await api.call(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 ( <> {st.s !== 'idle' && st.s !== 'dry' && ( { 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; p: OwnerActionProps; onGo: () => void; onCancel: () => void; onClose: () => void; }) { const root = useRef(null); const logRef = useRef(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 (
{ if (e.target === e.currentTarget && !running) onClose(); }}>

{p.label}

{st.dry.command}
{p.lead && {p.lead}} {st.s === 'plan' && st.note && {st.note}} {!st.dry.ok && {t('oa.dryBad')}{tail(st.dry.output) ? `: ${tail(st.dry.output)}` : ''}} {blockers.map((b, i) => {b})}
{t('oa.plan')}
{clean(st.dry.output) || t('oa.noOutput')}
{running && {t('oa.running')}} {st.s === 'done' && (st.ok ? {p.done}{st.detail ? `: ${st.detail}` : ''} : st.declined ? {t('write.declined')}{st.detail ? `: ${st.detail}` : ''} : {t('write.failed')}{st.detail ? `: ${st.detail}` : ''})} {lines.length > 0 &&
{lines.map(l => l.replace(/^\d\d:\d\d:\d\d /, '')).join('\n')}
}
{st.s === 'plan' ? (dryOk ? t('oa.foot') : t('oa.fixFirst')) : null}
{running ? : } {st.s === 'plan' && } {running && {t('oa.working')}}
); }