From 364b8ec0d4da0cc3195e11ca7082d95012ed712e Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 09:48:48 +0800 Subject: [PATCH] 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 --- apps/desktop/src/App.tsx | 2 ++ apps/desktop/src/design.css | 2 ++ apps/desktop/src/i18n/en.ts | 6 +++- apps/desktop/src/i18n/tr.ts | 6 +++- apps/desktop/src/store.tsx | 5 +++ apps/desktop/src/ui/DryRunPreview.tsx | 44 ++++++++++++++++++++++++++ apps/desktop/tests/dry-preview.test.ts | 15 +++++++++ 7 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/ui/DryRunPreview.tsx create mode 100644 apps/desktop/tests/dry-preview.test.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 30789c0..c90e4ee 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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 && setPalette(false)} />} {welcome && } + ); } diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index 5e7a471..6db53a4 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -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; } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index bf2e10a..b96579e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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/. 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", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 162cc43..977c9b4 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -83,6 +83,10 @@ export const tr: Record = { '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 = { '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/ 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", diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 711dbf5..ee77473 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -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(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 }); diff --git a/apps/desktop/src/ui/DryRunPreview.tsx b/apps/desktop/src/ui/DryRunPreview.tsx new file mode 100644 index 0000000..279c22f --- /dev/null +++ b/apps/desktop/src/ui/DryRunPreview.tsx @@ -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 { + 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(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 ( + done(false)} wide + actions={<> + + + }> +

{t('preview.lead')}

+ {ask.dry.command} + {lines.length ?
{lines.join('\n')}{more ? `\n${t('preview.more', { count: more })}` : ''}
+ :

{t('preview.nothing')}

} +
+ ); +} diff --git a/apps/desktop/tests/dry-preview.test.ts b/apps/desktop/tests/dry-preview.test.ts new file mode 100644 index 0000000..106d9e5 --- /dev/null +++ b/apps/desktop/tests/dry-preview.test.ts @@ -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); +});