diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index 5169c481f33..9e7c73c028b 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -16,6 +16,11 @@ import { collectMobileTranslationBindings, isMobileTranslationCall } from './mobile-localization-translation-bindings.mjs' +import { + findNewLocalizationCandidates, + findStaleLocalizationAllowlistEntries, + formatStaleLocalizationAllowlistEntries +} from './localization-allowlist-drift.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) @@ -550,58 +555,12 @@ function parseArgs(argv) { return options } -function candidateSignature(candidate) { - return JSON.stringify({ - filePath: candidate.filePath, - kind: candidate.kind, - text: candidate.text, - dynamic: candidate.dynamic - }) -} - -function countBySignature(reports) { - const counts = new Map() - for (const report of reports) { - const signature = candidateSignature(report) - counts.set(signature, (counts.get(signature) ?? 0) + 1) - } - return counts -} - async function readAllowlist(root, allowlistPath) { const absolutePath = path.resolve(root, allowlistPath) const raw = await fs.readFile(absolutePath, 'utf8') return JSON.parse(raw) } -function findNewCandidates(reports, allowlist) { - const allowedCounts = new Map( - allowlist.map((entry) => [ - JSON.stringify({ - filePath: entry.filePath, - kind: entry.kind, - text: entry.text, - dynamic: entry.dynamic - }), - entry.count - ]) - ) - const seenCounts = countBySignature(reports) - const newCandidates = [] - - for (const report of reports) { - const signature = candidateSignature(report) - const seenCount = seenCounts.get(signature) ?? 0 - const allowedCount = allowedCounts.get(signature) ?? 0 - if (seenCount > allowedCount) { - newCandidates.push(report) - seenCounts.set(signature, seenCount - 1) - } - } - - return newCandidates -} - export async function main(root = process.cwd(), argv = process.argv.slice(2)) { const options = parseArgs(argv) const absoluteSourceRoot = path.resolve(root, options.sourceRoot) @@ -614,8 +573,11 @@ export async function main(root = process.cwd(), argv = process.argv.slice(2)) { } if (options.check) { - const allowlist = await readAllowlist(root, options.allowlistPath) - const newCandidates = findNewCandidates(reports, allowlist) + const sourcePrefix = normalizePath(root, absoluteSourceRoot) + const allowlist = (await readAllowlist(root, options.allowlistPath)).filter( + (entry) => entry.filePath === sourcePrefix || entry.filePath.startsWith(`${sourcePrefix}/`) + ) + const newCandidates = findNewLocalizationCandidates(reports, allowlist) if (newCandidates.length > 0) { console.error('New unlocalized UI strings were found.') console.error('Localize them or add a reviewed exclusion to the localization allowlist.') @@ -623,6 +585,14 @@ export async function main(root = process.cwd(), argv = process.argv.slice(2)) { console.error(formatReports(root, newCandidates)) return 1 } + const staleEntries = findStaleLocalizationAllowlistEntries(reports, allowlist) + if (staleEntries.length > 0) { + console.error('Stale localization allowlist entries were found.') + console.error('Remove entries or lower counts that no longer match source candidates.') + console.error('') + console.error(formatStaleLocalizationAllowlistEntries(staleEntries)) + return 1 + } console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`) return 0 } diff --git a/config/scripts/localization-allowlist-drift.mjs b/config/scripts/localization-allowlist-drift.mjs new file mode 100644 index 00000000000..86d4f8836d7 --- /dev/null +++ b/config/scripts/localization-allowlist-drift.mjs @@ -0,0 +1,52 @@ +function candidateSignature(candidate) { + return JSON.stringify({ + filePath: candidate.filePath, + kind: candidate.kind, + text: candidate.text, + dynamic: candidate.dynamic + }) +} + +function countBySignature(candidates) { + const counts = new Map() + for (const candidate of candidates) { + const signature = candidateSignature(candidate) + counts.set(signature, (counts.get(signature) ?? 0) + 1) + } + return counts +} + +export function findNewLocalizationCandidates(reports, allowlist) { + const allowedCounts = new Map(allowlist.map((entry) => [candidateSignature(entry), entry.count])) + const seenCounts = countBySignature(reports) + const newCandidates = [] + + for (const report of reports) { + const signature = candidateSignature(report) + const seenCount = seenCounts.get(signature) ?? 0 + const allowedCount = allowedCounts.get(signature) ?? 0 + if (seenCount > allowedCount) { + newCandidates.push(report) + seenCounts.set(signature, seenCount - 1) + } + } + + return newCandidates +} + +export function findStaleLocalizationAllowlistEntries(reports, allowlist) { + const seenCounts = countBySignature(reports) + return allowlist.flatMap((entry) => { + const seenCount = seenCounts.get(candidateSignature(entry)) ?? 0 + return seenCount < entry.count ? [{ ...entry, seenCount }] : [] + }) +} + +export function formatStaleLocalizationAllowlistEntries(entries) { + return entries + .map( + (entry) => + `${entry.filePath} ${entry.kind}: ${JSON.stringify(entry.text)} (allowlisted ${entry.count}, found ${entry.seenCount})` + ) + .join('\n') +} diff --git a/config/scripts/mobile-embedded-document-candidates.mjs b/config/scripts/mobile-embedded-document-candidates.mjs index b8a30318454..b29d8277e9a 100644 --- a/config/scripts/mobile-embedded-document-candidates.mjs +++ b/config/scripts/mobile-embedded-document-candidates.mjs @@ -1,7 +1,6 @@ // TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. import ts from 'typescript-api' -const EMBEDDED_DOCUMENT_FILE_RE = /(?:-html|webview-html)\.[cm]?[jt]sx?$/ const USER_VISIBLE_ATTRIBUTE_RE = /\b(aria-description|aria-label|alt|data-placeholder|placeholder|title)\s*=\s*(["'])(.*?)\2/gi const USER_VISIBLE_PROMPT_START_RE = /\b(?:window\.)?(alert|confirm|prompt)\(\s*/g @@ -183,6 +182,14 @@ function insertedHtmlArgumentEnd(source, start) { cursor = skipQuotedString(source, cursor) continue } + if (source[cursor] === '`') { + cursor = templateLiteralEnd(source, cursor) + continue + } + if (source[cursor] === '\\' && source[cursor + 1] === '`') { + cursor = escapedTemplateLiteralEnd(source, cursor) + continue + } if (source[cursor] === '(') { depth += 1 } @@ -211,15 +218,22 @@ function collectInsertedHtmlCandidates(expression, baseOffset, candidates) { cursor = end continue } + const escapedTemplate = expression[cursor] === '\\' && expression[cursor + 1] === '`' + if (expression[cursor] === '`' || escapedTemplate) { + const end = escapedTemplate + ? escapedTemplateLiteralEnd(expression, cursor) + : templateLiteralEnd(expression, cursor) + const delimiterWidth = escapedTemplate ? 2 : 1 + const markup = expression.slice(cursor + delimiterWidth, end - delimiterWidth) + collectMarkupFragmentCandidates(markup, baseOffset + cursor + delimiterWidth, candidates) + cursor = end + continue + } cursor += 1 } } export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) { - if (!EMBEDDED_DOCUMENT_FILE_RE.test(filePath)) { - return [] - } - const sourceKind = filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS const sourceFile = ts.createSourceFile( diff --git a/config/scripts/mobile-localization-candidate-rules.mjs b/config/scripts/mobile-localization-candidate-rules.mjs index ffdaf6c5cb3..6e7bda8e23c 100644 --- a/config/scripts/mobile-localization-candidate-rules.mjs +++ b/config/scripts/mobile-localization-candidate-rules.mjs @@ -41,6 +41,7 @@ const USER_VISIBLE_OBJECT_KEYS = new Set([ 'placeholder', 'subject', 'subtitle', + 'successToast', 'title', 'toggleDescription', 'tooltip' @@ -112,6 +113,7 @@ const USER_VISIBLE_VARIABLE_SUFFIX_RE = const USER_VISIBLE_RETURN_FUNCTIONS = new Set([ 'accessibilityLabelForLine', 'autoRestoreSummary', + 'commentAuthor', 'commentSourceLabel', 'composerLabel', 'describeScope', @@ -460,7 +462,20 @@ export function classifyMobileStringNode(node, userVisibleErrorSource) { if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) { return undefined } - if (isAssignedToRenderedVariable(node)) { + if ( + isAssignedToRenderedVariable(node, (expression) => { + if (isRenderedJsxExpression(expression)) { + return true + } + const attributeName = ancestorJsxAttributeName(expression) + return Boolean( + attributeName && + (USER_VISIBLE_JSX_ATTRIBUTES.has(attributeName) || + MOBILE_USER_VISIBLE_JSX_ATTRIBUTES.has(attributeName) || + MOBILE_USER_VISIBLE_PROPERTY_SUFFIX_RE.test(attributeName)) + ) + }) + ) { return 'rendered-variable' } if (isComparisonOperand(node)) { diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs index dcfb4e884ba..c83884db988 100644 --- a/config/scripts/mobile-localization-candidate-rules.test.mjs +++ b/config/scripts/mobile-localization-candidate-rules.test.mjs @@ -1,6 +1,13 @@ -import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' -import { collectLocalizationCandidates } from './audit-localization-coverage.mjs' +import { describe, expect, it, vi } from 'vitest' + +import { + collectLocalizationCandidates, + main as auditLocalizationCoverage +} from './audit-localization-coverage.mjs' describe('mobile-localization-candidate-rules', () => { it('finds grammatical fragments returned by repositoryCount', () => { @@ -61,6 +68,28 @@ export const HTML = \` ]) }) + it('finds arbitrary doctype documents and template-literal insertHTML copy', () => { + const source = [ + 'export const HTML = `', + '
Diagram controls
', + '', + '`' + ].join('\n') + const candidates = collectLocalizationCandidates( + '/repo/mobile/src/components/pr-sidebar/MermaidDiagram.tsx', + source, + '/repo' + ) + + expect(candidates.map(({ dynamic, text }) => ({ dynamic, text }))).toEqual([ + { dynamic: false, text: 'Diagram controls' }, + { dynamic: true, text: 'Retry' }, + { dynamic: false, text: 'Follow up' } + ]) + }) + it('finds literals assigned to variables that later render in JSX', () => { const source = ` export function Example({ alternate }) { @@ -87,6 +116,56 @@ export function Example({ alternate }) { ]) }) + it('finds literals assigned to variables rendered through user-visible JSX attributes', () => { + const source = ` +export function Example() { + const copy = 'Save changes' + const className = 'not-visible-copy' + return ') expect(terminalHtml).toContain('id="sel-menu-all">Seleccionar todo') }) diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index c89fbd980d0..057edfbff8c 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -1041,6 +1041,10 @@ "commitCountCommits": "{{commitCount}} commits", "vs": "vs {{baseRef}}" }, + "mobileQuickCommandLaunch": { + "quickCommand": "Quick command", + "inserted": "{{commandLabel}} inserted" + }, "mobileCommitMessageAi": { "failed": "Failed to generate commit message", "no": "No commit message generated" diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 4e5c5537c59..42aa951b891 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -496,7 +496,10 @@ "medium": "Medio", "high": "Alto", "urgent": "Urgente", - "selected": "Seleccionado" + "selected": "Seleccionado", + "todo": "Tarea pendiente de {{todoTarget}}", + "iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}} d", + "action": "{{actionVerb}} {{taskKindLabel}}" }, "accounts": { "add": "Agregue o vuelva a autenticar cuentas desde Configuración de escritorio → Cuentas.", @@ -930,11 +933,16 @@ }, "mobileBranchCompare": { "committed": "Commits en la rama", - "changedFileCountFile": "Archivo {{changedFileCount}}", - "changedFileCountFiles": "Archivos {{changedFileCount}}", - "commitCountCommits": "{{commitCount}} se compromete", + "changedFileCountFile": "{{changedFileCount}} archivo", + "changedFileCountFiles": "{{changedFileCount}} archivos", + "commitCountCommit": "{{commitCount}} commit", + "commitCountCommits": "{{commitCount}} commits", "vs": "frente a {{baseRef}}" }, + "mobileQuickCommandLaunch": { + "quickCommand": "Comando rápido", + "inserted": "{{commandLabel}} insertado" + }, "mobileCommitMessageAi": { "failed": "No se pudo generar el mensaje de commit", "no": "No se generó ningún mensaje de commit" @@ -2486,5 +2494,10 @@ }, "filePreview": { "metaFallback": "Vista previa" + }, + "hostedReview": { + "mergeConfirm": "¿{{action}} {{target}} #{{number}}?", + "stateChangeConfirm": "¿{{action}} {{target}} #{{number}}?", + "stateChangeTitle": "{{action}} {{target}}" } } diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 88ad1d535c5..763383dc2df 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -508,7 +508,9 @@ "medium": "中くらい", "high": "高い", "urgent": "緊急", - "selected": "選択済み" + "selected": "選択済み", + "iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}}日間", + "action": "{{taskKindLabel}}を{{actionVerb}}" }, "accounts": { "host": "ホスト", @@ -948,7 +950,14 @@ "mobileBranchCompare": { "committed": "ブランチに Commit", "changedFileCountFile": "{{changedFileCount}} ファイル", - "changedFileCountFiles": "{{changedFileCount}} ファイル" + "changedFileCountFiles": "{{changedFileCount}} ファイル", + "commitCountCommit": "{{commitCount}} コミット", + "commitCountCommits": "{{commitCount}} コミット", + "vs": "{{baseRef}} と比較" + }, + "mobileQuickCommandLaunch": { + "quickCommand": "クイックコマンド", + "inserted": "{{commandLabel}}を挿入しました" }, "mobileCommitMessageAi": { "failed": "commit メッセージの生成に失敗しました", @@ -2527,5 +2536,10 @@ }, "filePreview": { "metaFallback": "プレビュー" + }, + "hostedReview": { + "mergeConfirm": "{{target}} #{{number}} を{{action}}しますか?", + "stateChangeConfirm": "{{target}} #{{number}} を{{action}}?", + "stateChangeTitle": "{{target}}を{{action}}" } } diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index a66e7c410e3..0b15b638c30 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -508,7 +508,9 @@ "medium": "중간", "high": "높음", "urgent": "긴급", - "selected": "선택된" + "selected": "선택된", + "iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}}일", + "action": "{{taskKindLabel}} {{actionVerb}}" }, "accounts": { "host": "호스트", @@ -948,6 +950,10 @@ "changedFileCountFiles": "{{changedFileCount}} 파일", "vs": "대 {{baseRef}}" }, + "mobileQuickCommandLaunch": { + "quickCommand": "빠른 명령", + "inserted": "{{commandLabel}} 삽입됨" + }, "mobileCommitMessageAi": { "failed": "commit 메시지를 생성하지 못했습니다.", "no": "commit 메시지가 생성되지 않았습니다." @@ -2526,5 +2532,10 @@ }, "filePreview": { "metaFallback": "미리보기" + }, + "hostedReview": { + "mergeConfirm": "{{target}} #{{number}} {{action}}?", + "stateChangeConfirm": "{{target}} #{{number}} {{action}}?", + "stateChangeTitle": "{{target}} {{action}}" } } diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index e0105e91735..1c4ea531f2e 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -507,7 +507,9 @@ "medium": "中等的", "high": "高的", "urgent": "紧迫的", - "selected": "已选择" + "selected": "已选择", + "iterationStatus": "{{iterationStatus}} · {{startDate}} · {{durationDays}} 天", + "action": "{{actionVerb}}{{taskKindLabel}}" }, "accounts": { "host": "主机", @@ -953,6 +955,10 @@ "changedFileCountFiles": "{{changedFileCount}} 文件", "vs": "与 {{baseRef}}" }, + "mobileQuickCommandLaunch": { + "quickCommand": "快捷命令", + "inserted": "已插入{{commandLabel}}" + }, "mobileCommitMessageAi": { "failed": "无法生成 commit 消息", "no": "没有生成 commit 消息" @@ -2538,5 +2544,10 @@ }, "filePreview": { "metaFallback": "预览" + }, + "hostedReview": { + "mergeConfirm": "{{action}}{{target}} #{{number}}?", + "stateChangeConfirm": "{{action}}{{target}} #{{number}}?", + "stateChangeTitle": "{{action}}{{target}}" } } diff --git a/mobile/src/i18n/mobile-i18n.test.ts b/mobile/src/i18n/mobile-i18n.test.ts index 55ec9cc0e6f..44e418acde5 100644 --- a/mobile/src/i18n/mobile-i18n.test.ts +++ b/mobile/src/i18n/mobile-i18n.test.ts @@ -3,6 +3,7 @@ import appConfig from '../../app.json' import { createMobileTranslator, + getActiveMobileUiLanguageTag, mobileI18n, normalizeMobileUiLocale, selectPreferredMobileUiLocale, @@ -55,6 +56,12 @@ describe('mobile i18n', () => { expect(createMobileTranslator('task')('gitHub')).toBe('GitHub') }) + it('exposes the effective BCP 47 language tag for embedded documents', async () => { + await mobileI18n.changeLanguage('zh') + + expect(getActiveMobileUiLanguageTag()).toBe('zh-Hans') + }) + it('enables localized native metadata on iOS', () => { expect(appConfig.expo.ios.infoPlist.CFBundleAllowMixedLocalizations).toBe(true) }) @@ -142,6 +149,36 @@ describe('mobile i18n', () => { ]) }) + it('keeps Japanese composite action grammar in the active locale', async () => { + await mobileI18n.changeLanguage('ja') + + expect([ + t('hostedReview.mergeConfirm', { + action: t('task.merge'), + target: t('task.pr'), + number: 42 + }), + t('hostedReview.stateChangeTitle', { + action: t('task.close'), + target: t('task.pullRequest') + }), + t('task.action', { + actionVerb: t('task.close'), + taskKindLabel: t('task.pullRequestMessage') + }) + ]).toEqual(['PR #42 をマージしますか?', 'PRを閉じる', 'PRを閉じる']) + }) + + it('renders Spanish branch counts with correct noun order and Git terminology', async () => { + await mobileI18n.changeLanguage('es') + + expect([ + t('mobileBranchCompare.changedFileCountFiles', { changedFileCount: 2 }), + t('mobileBranchCompare.commitCountCommit', { commitCount: 1 }), + t('mobileBranchCompare.commitCountCommits', { commitCount: 2 }) + ]).toEqual(['2 archivos', '1 commit', '2 commits']) + }) + it.each([ ['es', ['Omitido', 'Publicando rama...']], ['ja', ['スキップ済み', 'ブランチを公開しています...']], diff --git a/mobile/src/i18n/mobile-i18n.ts b/mobile/src/i18n/mobile-i18n.ts index 4ab71b6419c..591f68d736f 100644 --- a/mobile/src/i18n/mobile-i18n.ts +++ b/mobile/src/i18n/mobile-i18n.ts @@ -76,6 +76,11 @@ export function t(key: string, options?: TOptions): string { return mobileI18n.t(key, options) } +export function getActiveMobileUiLanguageTag(): string { + const locale = normalizeMobileUiLocale(mobileI18n.language) + return locale === 'zh' ? 'zh-Hans' : locale +} + export function createMobileTranslator(keyPrefix: string) { return (key: string, options?: TOptions): string => t(`${keyPrefix}.${key}`, options) } diff --git a/mobile/src/i18n/use-mobile-locale-reload.test.ts b/mobile/src/i18n/use-mobile-locale-reload.test.ts index 7b0e1884a39..a4e08494bd1 100644 --- a/mobile/src/i18n/use-mobile-locale-reload.test.ts +++ b/mobile/src/i18n/use-mobile-locale-reload.test.ts @@ -2,15 +2,15 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' -const { locales, reloadAppAsync } = vi.hoisted(() => ({ - locales: [{ languageTag: 'es-MX' }], +const { localeState, reloadAppAsync } = vi.hoisted(() => ({ + localeState: { current: [{ languageTag: 'es-MX' }] }, reloadAppAsync: vi.fn<() => Promise>() })) vi.mock('expo', () => ({ reloadAppAsync })) vi.mock('expo-localization', () => ({ getLocales: () => [{ languageTag: 'en' }], - useLocales: () => locales + useLocales: () => localeState.current })) import { useMobileLocaleReload } from './use-mobile-locale-reload' @@ -28,6 +28,7 @@ describe('useMobileLocaleReload', () => { vi.useFakeTimers() globalThis.IS_REACT_ACT_ENVIRONMENT = true reloadAppAsync.mockReset() + localeState.current = [{ languageTag: 'es-MX' }] const original = console.error consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { @@ -56,4 +57,33 @@ describe('useMobileLocaleReload', () => { }) expect(reloadAppAsync).toHaveBeenCalledTimes(2) }) + + it('retries a rejected request after locale preferences change while it is pending', async () => { + let rejectReload!: (error: Error) => void + reloadAppAsync.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectReload = reject + }) + ) + reloadAppAsync.mockResolvedValueOnce(undefined) + await act(async () => { + renderer = create(createElement(Harness)) + }) + expect(reloadAppAsync).toHaveBeenCalledTimes(1) + + localeState.current = [{ languageTag: 'ja-JP' }] + await act(async () => { + renderer?.update(createElement(Harness)) + }) + await act(async () => { + rejectReload(new Error('reload unavailable')) + await Promise.resolve() + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + + expect(reloadAppAsync).toHaveBeenCalledTimes(2) + }) }) diff --git a/mobile/src/i18n/use-mobile-locale-reload.ts b/mobile/src/i18n/use-mobile-locale-reload.ts index 96d9e9fd81b..c65e6aded5b 100644 --- a/mobile/src/i18n/use-mobile-locale-reload.ts +++ b/mobile/src/i18n/use-mobile-locale-reload.ts @@ -8,8 +8,28 @@ const LOCALE_RELOAD_RETRY_MS = 1_000 export function useMobileLocaleReload(): void { const locales = useLocales() const reloadRequestedRef = useRef(false) + const mountedRef = useRef(true) + const [retryPending, setRetryPending] = useState(false) const [retryVersion, setRetryVersion] = useState(0) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + useEffect(() => { + if (!retryPending) { + return + } + const retryTimer = setTimeout(() => { + setRetryPending(false) + setRetryVersion((current) => current + 1) + }, LOCALE_RELOAD_RETRY_MS) + return () => clearTimeout(retryTimer) + }, [retryPending]) + useEffect(() => { if ( reloadRequestedRef.current || @@ -21,23 +41,12 @@ export function useMobileLocaleReload(): void { return } reloadRequestedRef.current = true - let active = true - let retryTimer: ReturnType | null = null void reloadAppAsync('Mobile locale changed').catch(() => { - if (!active) { + reloadRequestedRef.current = false + if (!mountedRef.current) { return } - reloadRequestedRef.current = false - retryTimer = setTimeout( - () => setRetryVersion((current) => current + 1), - LOCALE_RELOAD_RETRY_MS - ) + setRetryPending(true) }) - return () => { - active = false - if (retryTimer) { - clearTimeout(retryTimer) - } - } }, [locales, retryVersion]) } diff --git a/mobile/src/terminal/quick-commands.test.ts b/mobile/src/terminal/quick-commands.test.ts index 964fbc9b4d2..07c7a1738fa 100644 --- a/mobile/src/terminal/quick-commands.test.ts +++ b/mobile/src/terminal/quick-commands.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { TerminalQuickCommand } from '../../../src/shared/types' +import { mobileI18n } from '../i18n/mobile-i18n' import { buildMobileQuickCommandLaunch, getQuickCommandDisplayPreview, @@ -7,6 +8,12 @@ import { supportsMobileQuickCommands } from './quick-commands' +const INITIAL_LOCALE = mobileI18n.language + +afterEach(async () => { + await mobileI18n.changeLanguage(INITIAL_LOCALE) +}) + function command(overrides: Partial = {}): TerminalQuickCommand { return { id: 'command', @@ -58,6 +65,16 @@ describe('mobile quick-command launch', () => { }) }) + it('localizes the unsubmitted-command confirmation', async () => { + await mobileI18n.changeLanguage('es') + + expect(buildMobileQuickCommandLaunch(command({ label: '', appendEnter: false }))).toMatchObject( + { + options: { successToast: 'Comando rápido insertado' } + } + ) + }) + it('injects supported agent prompts into the host-built launch command', () => { expect( buildMobileQuickCommandLaunch( diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts index 40f92a55439..c732231a77e 100644 --- a/mobile/src/terminal/quick-commands.ts +++ b/mobile/src/terminal/quick-commands.ts @@ -13,6 +13,7 @@ import { type TerminalQuickCommandMutation } from '../../../src/shared/terminal-quick-commands' import { TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { t } from '../i18n/mobile-i18n' import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents' // Reuse the canonical desktop quick-command logic (pure, no heavy deps) so @@ -66,7 +67,9 @@ export function buildMobileQuickCommandLaunch( options: { initialPrompt: command.command, enter: false, - successToast: `${command.label.trim() || 'Quick command'} inserted` + successToast: t('mobileQuickCommandLaunch.inserted', { + commandLabel: command.label.trim() || t('mobileQuickCommandLaunch.quickCommand') + }) } } : { diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 2cb767f100c..7b21572923a 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,7 +1,7 @@ // xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget. import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy' -import { t } from '../i18n/mobile-i18n' +import { getActiveMobileUiLanguageTag, t } from '../i18n/mobile-i18n' import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' @@ -52,7 +52,7 @@ export const MOBILE_TERMINAL_CARET_OPTIONS = { // Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. export function buildTerminalWebViewHtml(): string { return ` - +