desktop: More tools tab, one typed form per CLI command without a screen (schema from system.methods; reads run, writes dry run + native dialog + token) and the owner steps with their terminal command; en + tr

This commit is contained in:
CRTkafa 2026-10-21 05:02:20 +07:00
parent ce1b1edcb8
commit a95aa6dd49
7 changed files with 231 additions and 3 deletions

View file

@ -24,6 +24,7 @@ import { PricesTab } from './tabs/Prices';
import { ReleasesTab } from './tabs/Releases';
import { ReviewsTab } from './tabs/Reviews';
import { TestFlightTab } from './tabs/TestFlight';
import { ToolsTab } from './tabs/Tools';
import { EDIT_TABS, TabEditor, useUnsavedGuard } from './editors/TabEditor';
import { StoreListingEditor } from './editors/StoreListingEditor';
import { useEdits } from '../ui/Editor';
@ -171,6 +172,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'growth' && <GrowthTab app={app} />}
{tab === 'appinfo' && app.project && <AppInfoTab project={app.project} />}
{tab === 'checks' && app.project && <ChecksTab project={app.project} />}
{tab === 'tools' && app.project && <ToolsTab project={app.project} />}
{tab !== 'listings' && app.project && !app.project.missing && EDIT_TABS[tab] && <TabEditor tab={tab} project={app.project} />}
</ErrorBoundary>
</div>

View file

@ -0,0 +1,140 @@
// More tools: the CLI commands without a screen of their own (cli/lib/desktop-coverage.ts TOOLBOX), one typed form
// per serve method built from its argument schema (system.methods). Never a free-text shell: only the method's own
// options, validated by the sidecar. Reads run here; writes go through the dry run, the native dialog and the confirm
// token (useWrite). Owner-only steps without a button are listed with their terminal command to copy.
import { useEffect, useState } from 'react';
import type { JsonSchemaLite } from '../../../../../cli/lib/desktop-protocol';
import { OWNER_STEPS, TOOLBOX } from '../../../../../cli/lib/desktop-coverage';
import { api, errorText, type MethodDescriptor, newJob, type Project, type ToolResult } from '../../api';
import { type Key, t } from '../../i18n';
import { useData, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, ErrorNotice, Notice, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
type Args = Record<string, unknown>;
/** Only the values the owner set (empty fields are left out, so the command's defaults apply). */
export function cleanArgs(schema: MethodDescriptor['args'], v: Args): Args {
const out: Args = {};
for (const [k, s] of Object.entries(schema.properties)) {
const x = v[k];
if (x === undefined || x === '' || x === null) continue;
if (s.type === 'boolean') { if (x === true) out[k] = true; continue; }
out[k] = s.type === 'number' || s.type === 'integer' ? Number(x) : x;
}
return out;
}
function Field({ name, s, value, required, onChange }: { name: string; s: JsonSchemaLite; value: unknown; required: boolean; onChange: (v: unknown) => void }) {
const label = <span className="label">{name.replace(/_/g, ' ')}{required ? ' *' : ''}</span>;
if (s.type === 'boolean') {
return <label className="row" style={{ gap: 6 }} title={s.description}><input type="checkbox" checked={value === true} onChange={e => onChange(e.target.checked)} />{label}</label>;
}
if (s.enum) {
return (
<label className="stack" style={{ gap: 2 }} title={s.description}>{label}
<select className="input" value={String(value ?? '')} onChange={e => onChange(e.target.value)}>
<option value="">{s.default !== undefined ? t('tools.default', { value: String(s.default) }) : '–'}</option>
{s.enum.map(o => <option key={String(o)} value={String(o)}>{String(o)}</option>)}
</select>
</label>
);
}
const num = s.type === 'number' || s.type === 'integer';
return (
<label className="stack" style={{ gap: 2 }} title={s.description}>{label}
<input className="input" type={num ? 'number' : 'text'} value={String(value ?? '')} placeholder={s.description ?? ''}
pattern={s.pattern} min={s.minimum} max={s.maximum} onChange={e => onChange(e.target.value)} />
</label>
);
}
function Tool({ m, project }: { m: MethodDescriptor; project: Project }) {
const d = useData();
const w = useWrite(project.id);
const [v, setV] = useState<Args>({});
const [busy, setBusy] = useState(false);
const [res, setRes] = useState<ToolResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const props = Object.entries(m.args?.properties ?? {});
const required = new Set(m.args?.required ?? []);
const args = m.args ? cleanArgs(m.args, v) : {};
const missing = [...required].some(k => args[k] === undefined);
const read = async () => {
setBusy(true); setErr(null);
try { setRes(await api.call<ToolResult>(m.method, project.id, { args }, newJob(m.method))); } catch (e) { setErr(errorText(e)); setRes(null); }
setBusy(false);
};
return (
<div className="tool" data-testid={`tool-${m.method}`}>
<div className="row between">
<div className="stack" style={{ gap: 2 }}>
<span className="strong">{m.title}</span>
<span className="small muted">{m.description}</span>
</div>
<div className="row" style={{ gap: 6 }}>
<Chip tone="outline" dot={false}>{m.kind === 'read' ? t('tools.read') : t('tools.write')}</Chip>
{m.network && <Chip tone="outline" dot={false} icon="cloud">{t('tools.network')}</Chip>}
</div>
</div>
{props.length > 0 && (
<div className="tool-form">
{props.map(([k, s]) => <Field key={k} name={k} s={s} value={v[k]} required={required.has(k)} onChange={x => setV(p => ({ ...p, [k]: x }))} />)}
</div>
)}
<div className="actions">
{m.kind === 'read'
? <button className="btn btn-sm" disabled={busy || missing} aria-busy={busy || undefined} onClick={() => { void read(); }}><Icon name={busy ? 'refresh' : 'play'} className={busy ? 'spin' : undefined} />{t('tools.run')}</button>
: <WriteButton run={w} icon="play" disabled={missing} w={{ method: m.method, args, title: m.title, done: t('tools.done', { title: m.title }) }}>{t('tools.dryRun')}</WriteButton>}
</div>
{err && <ErrorNotice error={err} />}
{res && (
<div className="stack" style={{ gap: 4 }}>
<span className="small muted">{res.command} · {t('tools.exit', { code: res.exitCode })}</span>
<pre className="stack-trace" data-testid="tool-output">{res.output || res.error || (res.data !== undefined ? JSON.stringify(res.data, null, 2) : '')}</pre>
<button className="btn btn-sm btn-ghost" onClick={() => { navigator.clipboard?.writeText(res.output).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); }}><Icon name="copy" />{t('tools.copy')}</button>
</div>
)}
</div>
);
}
export function ToolsTab({ project }: { project: Project }) {
const d = useData();
const [methods, setMethods] = useState<MethodDescriptor[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [open, setOpen] = useState<string>(TOOLBOX[0]!.id);
useEffect(() => {
api.call<{ methods: MethodDescriptor[] }>('system.methods').then(r => setMethods(r.methods), e => setErr(errorText(e)));
}, []);
const by = new Map((methods ?? []).map(m => [m.method, m] as const));
const copy = (c: string) => { navigator.clipboard?.writeText(c).then(() => d.toast({ kind: 'ok', title: t('tools.copied') }), () => {}); };
return (
<>
<Notice tone="info" icon="info">{t('tools.lead')}</Notice>
{err && <ErrorNotice error={err} />}
{!methods && !err && <Skeleton lines={4} />}
{methods && TOOLBOX.map(g => {
const ms = g.methods.map(x => by.get(x)).filter((x): x is MethodDescriptor => !!x);
const isOpen = open === g.id;
return (
<Card key={g.id} id={`tools-${g.id}`} title={t(`tools.g.${g.id}` as Key)} icon="sliders" sub={t('tools.count', { count: ms.length })}
actions={<button className="btn btn-sm btn-ghost" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? '' : g.id)}><Icon name={isOpen ? 'chevronDown' : 'chevronRight'} />{isOpen ? t('tools.hide') : t('tools.show')}</button>}>
{isOpen && <div className="stack" style={{ gap: 14 }}>{ms.map(m => <Tool key={m.method} m={m} project={project} />)}</div>}
</Card>
);
})}
<Card title={t('tools.owner')} icon="lock" sub={t('tools.ownerSub')} id="tools-owner">
<div className="stack" style={{ gap: 8 }}>
{OWNER_STEPS.map(s => (
<div key={s.id} className="row between" data-testid={`owner-step-${s.id}`}>
<div className="stack" style={{ gap: 2 }}><span>{t(`tools.o.${s.id}` as Key)}</span><code className="small mono-id">{s.command}</code></div>
<button className="btn btn-sm btn-ghost" onClick={() => copy(s.command)}><Icon name="copy" />{t('tools.copy')}</button>
</div>
))}
</div>
</Card>
</>
);
}