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:
parent
ce1b1edcb8
commit
a95aa6dd49
7 changed files with 231 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
|
|||
140
apps/desktop/src/screens/tabs/Tools.tsx
Normal file
140
apps/desktop/src/screens/tabs/Tools.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue