Desktop: every write shows a styled in-app dry-run preview (command, changed rows) before the native confirm dialog; i18n house style fix for the developer id hint
This commit is contained in:
parent
c86b583830
commit
364b8ec0d4
7 changed files with 78 additions and 2 deletions
|
|
@ -1,5 +1,6 @@
|
|||
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh),
|
||||
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
||||
import { DryRunPreview } from './ui/DryRunPreview';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
|
||||
import { devSwitch } from './dev/switches';
|
||||
|
|
@ -282,6 +283,7 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
{palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>}
|
||||
{welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>}
|
||||
<ErrorBoundary where="toasts"><Toasts /></ErrorBoundary>
|
||||
<ErrorBoundary where="preview"><DryRunPreview /></ErrorBoundary>
|
||||
</NavCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,3 +237,5 @@
|
|||
.ds-tab[hidden] { display: none; }
|
||||
.ds-tab-more svg:last-child { width: 12px; height: 12px; }
|
||||
@media (max-width: 1560px) { .ds-tabs .ds-tab { padding: 0 10px; gap: 6px; } .ds-tabs .ds-tab svg { width: 15px; height: 15px; } }
|
||||
.dr-cmd { display: block; margin: 10px 0 8px; padding: 6px 10px; border-radius: var(--ds-r-xs); background: var(--ds-inset); overflow-wrap: anywhere; }
|
||||
.dr-out { max-height: 320px; overflow: auto; margin: 0; padding: 10px 12px; border-radius: var(--ds-r-sm); background: var(--ds-inset); border: 1px solid var(--ds-stroke); font: 12px/1.5 var(--mono, ui-monospace, monospace); white-space: pre-wrap; }
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export const en = {
|
|||
'tools.noHits': "No command matches. Try a store word (listing, prices, testflight...).",
|
||||
'set.search': "Search settings",
|
||||
'set.noHits': "No setting matches",
|
||||
'preview.continue': "Continue to confirm",
|
||||
'preview.lead': "Dry run: nothing is changed yet. Continue opens the confirmation, which runs the dry run once more and then applies it.",
|
||||
'preview.more': "… {count} more lines",
|
||||
'preview.nothing': "The dry run reports no change.",
|
||||
'palette.gActions': 'Actions',
|
||||
'palette.openApp': 'Open',
|
||||
|
||||
|
|
@ -2066,7 +2070,7 @@ export const en = {
|
|||
'acct.devId': 'Play developer id {id}',
|
||||
'acct.noDevId': 'no Play developer id',
|
||||
'acct.devIdLabel': 'Play developer account id',
|
||||
'acct.devIdHint': "The digits in play.google.com/console/u/0/developers/<id>. With it, Play Console links open each app's own page; the CLI uses it too.",
|
||||
'acct.devIdHint': "The digits after /developers/ in the Play Console address. With it, Play Console links open each app's own page; the CLI uses it too.",
|
||||
'checks.openGuide': 'Open guide',
|
||||
'checks.openGuideHint': 'Open the {skill} guide (SKILL.md)',
|
||||
'cm.more': "More actions",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'tools.noHits': "Eşleşen komut yok. Bir mağaza kelimesi deneyin (listing, prices, testflight...).",
|
||||
'set.search': "Ayarlarda ara",
|
||||
'set.noHits': "Eşleşen ayar yok",
|
||||
'preview.continue': "Onaya geç",
|
||||
'preview.lead': "Deneme çalıştırması: henüz hiçbir şey değişmedi. Devam onay penceresini açar; deneme bir kez daha çalışır, sonra uygulanır.",
|
||||
'preview.more': "… {count} satır daha",
|
||||
'preview.nothing': "Deneme çalıştırması bir değişiklik bildirmiyor.",
|
||||
'palette.gActions': 'İşlemler',
|
||||
'palette.openApp': 'Aç',
|
||||
|
||||
|
|
@ -2068,7 +2072,7 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'acct.devId': 'Play geliştirici kimliği {id}',
|
||||
'acct.noDevId': 'Play geliştirici kimliği yok',
|
||||
'acct.devIdLabel': 'Play geliştirici hesabı kimliği',
|
||||
'acct.devIdHint': 'play.google.com/console/u/0/developers/<id> içindeki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.',
|
||||
'acct.devIdHint': 'Play Console adresinde /developers/ sonrasındaki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.',
|
||||
'checks.openGuide': 'Kılavuzu aç',
|
||||
'checks.openGuideHint': '{skill} kılavuzunu aç (SKILL.md)',
|
||||
'cm.more': "Diğer işlemler",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// App data shared by every screen: app folders, studios, store accounts and the apps they see, the pairing / ignore
|
||||
// overrides, settings, the sidecar, the portfolio (offline check + the last live read from the cache), app icons, and
|
||||
// background jobs shown as toasts with progress and cancel.
|
||||
import { askPreview } from './ui/DryRunPreview';
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload,
|
||||
|
|
@ -573,6 +574,10 @@ export function useWrite(projectId: string | null) {
|
|||
setBusy(key);
|
||||
const tid = d.toast({ id: job, kind: 'job', title: w.title, detail: t('write.dryRunning'), progress: null, job });
|
||||
try {
|
||||
// the in-app preview of the dry run first (readable, styled); the host's native dialog stays the real gate
|
||||
const dry = await api.call<ToolResult>(w.method, projectId, { args: w.args }, job);
|
||||
if (!dry.ok) { d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(dry)), progress: undefined, job: undefined }); return null; }
|
||||
if (!(await askPreview(w.title, dry))) { d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined }); return null; }
|
||||
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
|
||||
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
|
||||
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
|
||||
|
|
|
|||
44
apps/desktop/src/ui/DryRunPreview.tsx
Normal file
44
apps/desktop/src/ui/DryRunPreview.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// The in-app dry-run preview of a write: before the host's native confirm dialog (which stays the real gate, with its
|
||||
// own fresh dry run and the token for the real run), the dry run shows here, readable: the command, the rows it would
|
||||
// change, Continue / Cancel. Mounted once in App; useWrite asks it through askPreview().
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ToolResult } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { Dialog } from './ds';
|
||||
import { Icon } from './icons';
|
||||
|
||||
interface Ask { title: string; dry: ToolResult; resolve: (go: boolean) => void }
|
||||
let show: ((a: Ask) => void) | null = null;
|
||||
|
||||
/** Shows the preview; true = Continue (then the native dialog), false = Cancel. No preview mounted = true. */
|
||||
export function askPreview(title: string, dry: ToolResult): Promise<boolean> {
|
||||
return new Promise(resolve => { if (!show) resolve(true); else show({ title, dry, resolve }); });
|
||||
}
|
||||
|
||||
/** The changed rows of a dry run: lines that say what would change, else the last lines. */
|
||||
export function previewLines(output: string, max = 14): { lines: string[]; more: number } {
|
||||
const all = output.split(/\r?\n/).map(l => l.trimEnd()).filter(l => l.trim());
|
||||
const change = all.filter(l => /^\s*[+\-~*]\s|→|->|would|create|update|delete|change|set /i.test(l));
|
||||
const pick = change.length ? change : all.slice(-max);
|
||||
return { lines: pick.slice(0, max), more: Math.max(0, pick.length - max) };
|
||||
}
|
||||
|
||||
export function DryRunPreview() {
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
useEffect(() => { show = setAsk; return () => { show = null; }; }, []);
|
||||
if (!ask) return null;
|
||||
const done = (go: boolean) => { ask.resolve(go); setAsk(null); };
|
||||
const { lines, more } = previewLines(ask.dry.output ?? '');
|
||||
return (
|
||||
<Dialog title={ask.title} icon="eye" onClose={() => done(false)} wide
|
||||
actions={<>
|
||||
<button className="btn" onClick={() => done(false)}>{t('common.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={() => done(true)} autoFocus><Icon name="arrowRight" />{t('preview.continue')}</button>
|
||||
</>}>
|
||||
<p className="muted">{t('preview.lead')}</p>
|
||||
<code className="mono-id dr-cmd">{ask.dry.command}</code>
|
||||
{lines.length ? <pre className="dr-out" data-testid="dry-preview">{lines.join('\n')}{more ? `\n${t('preview.more', { count: more })}` : ''}</pre>
|
||||
: <p className="muted">{t('preview.nothing')}</p>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
15
apps/desktop/tests/dry-preview.test.ts
Normal file
15
apps/desktop/tests/dry-preview.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { expect, test } from 'bun:test';
|
||||
import { previewLines } from '../src/ui/DryRunPreview';
|
||||
|
||||
test('change lines are picked from a dry run', () => {
|
||||
const out = 'reading config\nconnected\n ~ en-US title: Old -> New\n + de-DE short description\ndone';
|
||||
expect(previewLines(out).lines).toEqual([' ~ en-US title: Old -> New', ' + de-DE short description']);
|
||||
});
|
||||
test('no change lines: the last lines', () => {
|
||||
expect(previewLines('a\nb\nc', 2).lines).toEqual(['b', 'c']);
|
||||
});
|
||||
test('long previews say how many more', () => {
|
||||
const out = Array.from({ length: 20 }, (_, i) => `+ row ${i}`).join('\n');
|
||||
const p = previewLines(out, 5);
|
||||
expect(p.lines.length).toBe(5); expect(p.more).toBe(15);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue