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
+}
+`
+ const candidates = collectLocalizationCandidates(
+ '/repo/mobile/src/components/Example.tsx',
+ source,
+ '/repo'
+ )
+
+ expect(candidates.map((candidate) => candidate.text)).toEqual(['Save changes'])
+ })
+
+ it('finds unlocalized success-toast object copy', () => {
+ const source = `
+export const launch = {
+ options: { successToast: 'Quick command inserted' }
+}
+`
+ const candidates = collectLocalizationCandidates(
+ '/repo/mobile/src/terminal/quick-commands.ts',
+ source,
+ '/repo'
+ )
+
+ expect(candidates.map((candidate) => candidate.text)).toEqual(['Quick command inserted'])
+ })
+
+ it('finds unlocalized fallbacks returned by commentAuthor', () => {
+ const source = `
+function commentAuthor(comment) {
+ return comment.author ?? 'unknown'
+}
+export function Example({ comment }) {
+ return {commentAuthor(comment)}
+}
+`
+ const candidates = collectLocalizationCandidates(
+ '/repo/mobile/app/h/[hostId]/tasks.tsx',
+ source,
+ '/repo'
+ )
+
+ expect(candidates.map((candidate) => candidate.text)).toEqual(['unknown'])
+ })
+
it('finds user-visible subject fallbacks in returned rows', () => {
const source = `
export function toRow(item) {
@@ -118,4 +197,43 @@ export function Example(translateMobile) {
expect(candidates.map((candidate) => candidate.text)).toEqual(['Unlocalized shadowed copy'])
})
+
+ it('rejects stale allowlist entries before their approval can be reused', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-allowlist-'))
+ const sourceDirectory = path.join(root, 'mobile', 'src')
+ const configDirectory = path.join(root, 'config')
+ mkdirSync(sourceDirectory, { recursive: true })
+ mkdirSync(configDirectory, { recursive: true })
+ writeFileSync(path.join(sourceDirectory, 'Example.tsx'), "export const value = 'internal'\n")
+ writeFileSync(
+ path.join(configDirectory, 'allowlist.json'),
+ `${JSON.stringify([
+ {
+ filePath: 'mobile/src/Example.tsx',
+ kind: 'jsx-text',
+ text: 'Removed approved copy',
+ dynamic: false,
+ count: 1
+ }
+ ])}\n`
+ )
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ try {
+ await expect(
+ auditLocalizationCoverage(root, [
+ '--check',
+ '--source-root',
+ 'mobile/src',
+ '--allowlist',
+ 'config/allowlist.json'
+ ])
+ ).resolves.toBe(1)
+ expect(error.mock.calls.flat().join('\n')).toContain(
+ 'Stale localization allowlist entries were found.'
+ )
+ } finally {
+ error.mockRestore()
+ }
+ })
})
diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs
index f2b40edacc2..6f4a6cff6eb 100644
--- a/config/scripts/mobile-localization-rendered-variable.mjs
+++ b/config/scripts/mobile-localization-rendered-variable.mjs
@@ -88,7 +88,7 @@ function assignedVariableName(node) {
: undefined
}
-export function isAssignedToRenderedVariable(node) {
+export function isAssignedToRenderedVariable(node, isRenderedExpression = isRenderedJsxExpression) {
if (ts.isTemplateExpression(node)) {
return false
}
@@ -108,7 +108,7 @@ export function isAssignedToRenderedVariable(node) {
if (rendered) {
return
}
- if (ts.isIdentifier(current) && current.text === name && isRenderedJsxExpression(current)) {
+ if (ts.isIdentifier(current) && current.text === name && isRenderedExpression(current)) {
rendered = true
return
}
diff --git a/config/scripts/mobile-localization-translation-bindings.mjs b/config/scripts/mobile-localization-translation-bindings.mjs
index b71f7970bb9..48ec4c38d15 100644
--- a/config/scripts/mobile-localization-translation-bindings.mjs
+++ b/config/scripts/mobile-localization-translation-bindings.mjs
@@ -17,10 +17,23 @@ function bindingNames(name, names = []) {
return names
}
-function lexicalScope(node, sourceFile) {
+function isLexicalScope(node) {
+ return (
+ ts.isSourceFile(node) ||
+ ts.isFunctionLike(node) ||
+ ts.isBlock(node) ||
+ ts.isCaseBlock(node) ||
+ ts.isCatchClause(node) ||
+ ts.isForStatement(node) ||
+ ts.isForInStatement(node) ||
+ ts.isForOfStatement(node)
+ )
+}
+
+function nearestScope(node, sourceFile, predicate = isLexicalScope) {
let current = node.parent
while (current && current !== sourceFile) {
- if (ts.isBlock(current) || ts.isSourceFile(current) || ts.isFunctionLike(current)) {
+ if (predicate(current)) {
return current
}
current = current.parent
@@ -29,29 +42,37 @@ function lexicalScope(node, sourceFile) {
}
function functionScope(node, sourceFile) {
- let current = node.parent
- while (current && current !== sourceFile) {
- if (ts.isFunctionLike(current)) {
- return current
- }
- current = current.parent
- }
- return sourceFile
+ return nearestScope(node, sourceFile, ts.isFunctionLike)
}
-function addDeclarations(sourceFile) {
+function collectDeclarations(sourceFile) {
const declarations = new Map()
- function add(scope, name) {
- const names = declarations.get(scope) ?? new Set()
- names.add(name)
- declarations.set(scope, names)
+ function add(scope, name, declaration) {
+ const byName = declarations.get(scope) ?? new Map()
+ const matches = byName.get(name) ?? []
+ matches.push(declaration)
+ byName.set(name, matches)
+ declarations.set(scope, byName)
}
function visit(node) {
- if (ts.isParameter(node)) {
+ if (ts.isImportDeclaration(node)) {
+ const clause = node.importClause
+ if (clause?.name) {
+ add(sourceFile, clause.name.text, clause.name)
+ }
+ if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
+ add(sourceFile, clause.namedBindings.name.text, clause.namedBindings)
+ }
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
+ for (const element of clause.namedBindings.elements) {
+ add(sourceFile, element.name.text, element)
+ }
+ }
+ } else if (ts.isParameter(node)) {
for (const name of bindingNames(node.name)) {
- add(node.parent, name)
+ add(node.parent, name, node)
}
} else if (ts.isVariableDeclaration(node)) {
const declarationList = node.parent
@@ -59,15 +80,17 @@ function addDeclarations(sourceFile) {
ts.isVariableDeclarationList(declarationList) &&
(declarationList.flags & ts.NodeFlags.BlockScoped) === 0
? functionScope(node, sourceFile)
- : lexicalScope(node, sourceFile)
+ : nearestScope(node, sourceFile)
for (const name of bindingNames(node.name)) {
- add(scope, name)
+ add(scope, name, node)
}
} else if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
- add(lexicalScope(node, sourceFile), node.name.text)
+ add(nearestScope(node, sourceFile), node.name.text, node)
+ } else if ((ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name) {
+ add(node, node.name.text, node)
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
for (const name of bindingNames(node.variableDeclaration.name)) {
- add(node, name)
+ add(node, name, node.variableDeclaration)
}
}
ts.forEachChild(node, visit)
@@ -77,24 +100,46 @@ function addDeclarations(sourceFile) {
return declarations
}
-function isShadowed(identifier, sourceFile, declarations) {
+function resolveBinding(identifier, sourceFile, declarations) {
let current = identifier.parent
- while (current && current !== sourceFile) {
- if (declarations.get(current)?.has(identifier.text)) {
- return true
+ while (current) {
+ const matches = declarations.get(current)?.get(identifier.text)
+ if (matches?.length) {
+ return matches[0]
+ }
+ if (current === sourceFile) {
+ break
}
current = current.parent
}
- return false
+ return undefined
}
-function isEnglishFixedTranslator(node, instanceNames, sourceFile, declarations) {
+function resolvesTo(identifier, bindings) {
+ return bindings.resolveBinding(identifier)
+}
+
+function isNamespaceMember(node, memberName, bindings) {
+ return (
+ ts.isPropertyAccessExpression(node) &&
+ node.name.text === memberName &&
+ ts.isIdentifier(node.expression) &&
+ bindings.namespaceBindings.has(resolvesTo(node.expression, bindings))
+ )
+}
+
+function isMobileI18nInstance(node, bindings) {
+ if (ts.isIdentifier(node)) {
+ return bindings.instanceBindings.has(resolvesTo(node, bindings))
+ }
+ return isNamespaceMember(node, 'mobileI18n', bindings)
+}
+
+function isEnglishFixedTranslator(node, bindings) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
- ts.isIdentifier(node.expression.expression) &&
- instanceNames.has(node.expression.expression.text) &&
- !isShadowed(node.expression.expression, sourceFile, declarations) &&
+ isMobileI18nInstance(node.expression.expression, bindings) &&
node.expression.name.text === 'getFixedT' &&
node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0]) &&
@@ -102,12 +147,17 @@ function isEnglishFixedTranslator(node, instanceNames, sourceFile, declarations)
)
}
-function mobileTranslatorPrefix(node, factoryNames, sourceFile, declarations) {
+function isTranslatorFactory(node, bindings) {
+ if (ts.isIdentifier(node)) {
+ return bindings.factoryBindings.has(resolvesTo(node, bindings))
+ }
+ return isNamespaceMember(node, 'createMobileTranslator', bindings)
+}
+
+function mobileTranslatorPrefix(node, bindings) {
if (
!ts.isCallExpression(node) ||
- !ts.isIdentifier(node.expression) ||
- !factoryNames.has(node.expression.text) ||
- isShadowed(node.expression, sourceFile, declarations) ||
+ !isTranslatorFactory(node.expression, bindings) ||
node.arguments.length !== 1 ||
!ts.isStringLiteralLike(node.arguments[0])
) {
@@ -116,13 +166,51 @@ function mobileTranslatorPrefix(node, factoryNames, sourceFile, declarations) {
return node.arguments[0].text
}
+function directTranslatorPrefix(callee, bindings) {
+ if (ts.isIdentifier(callee)) {
+ const binding = resolvesTo(callee, bindings)
+ if (bindings.translatorBindings.has(binding) || bindings.fixedTranslatorBindings.has(binding)) {
+ return ''
+ }
+ return bindings.prefixedTranslatorBindings.get(binding)
+ }
+ if (isNamespaceMember(callee, 't', bindings)) {
+ return ''
+ }
+ if (
+ ts.isPropertyAccessExpression(callee) &&
+ callee.name.text === 't' &&
+ isMobileI18nInstance(callee.expression, bindings)
+ ) {
+ return ''
+ }
+ return undefined
+}
+
export function collectMobileTranslationBindings(sourceFile) {
+ const declarations = collectDeclarations(sourceFile)
+ const translatorBindings = new Set()
+ const factoryBindings = new Set()
+ const instanceBindings = new Set()
+ const namespaceBindings = new Set()
+ const fixedTranslatorBindings = new Set()
+ const prefixedTranslatorBindings = new Map()
const translatorNames = new Set()
- const translatorFactoryNames = new Set()
- const instanceNames = new Set()
const fixedTranslatorNames = new Set()
const prefixedTranslatorNames = new Map()
- const declarations = addDeclarations(sourceFile)
+ const bindings = {
+ declarations,
+ factoryBindings,
+ fixedTranslatorBindings,
+ fixedTranslatorNames,
+ instanceBindings,
+ namespaceBindings,
+ prefixedTranslatorBindings,
+ prefixedTranslatorNames,
+ resolveBinding: (identifier) => resolveBinding(identifier, sourceFile, declarations),
+ translatorBindings,
+ translatorNames
+ }
for (const statement of sourceFile.statements) {
if (
@@ -132,68 +220,52 @@ export function collectMobileTranslationBindings(sourceFile) {
) {
continue
}
- for (const element of statement.importClause?.namedBindings?.elements ?? []) {
+ const namedBindings = statement.importClause?.namedBindings
+ if (namedBindings && ts.isNamespaceImport(namedBindings)) {
+ namespaceBindings.add(namedBindings)
+ continue
+ }
+ if (!namedBindings || !ts.isNamedImports(namedBindings)) {
+ continue
+ }
+ for (const element of namedBindings.elements) {
const importedName = element.propertyName?.text ?? element.name.text
if (importedName === 't') {
+ translatorBindings.add(element)
translatorNames.add(element.name.text)
} else if (importedName === 'createMobileTranslator') {
- translatorFactoryNames.add(element.name.text)
+ factoryBindings.add(element)
} else if (importedName === 'mobileI18n') {
- instanceNames.add(element.name.text)
+ instanceBindings.add(element)
}
}
}
- function collectFixed(node) {
- if (
- ts.isVariableDeclaration(node) &&
- ts.isIdentifier(node.name) &&
- node.initializer &&
- isEnglishFixedTranslator(node.initializer, instanceNames, sourceFile, declarations)
- ) {
- fixedTranslatorNames.add(node.name.text)
- }
+ function collectLocalTranslators(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
- const prefix = mobileTranslatorPrefix(
- node.initializer,
- translatorFactoryNames,
- sourceFile,
- declarations
- )
+ const binding = bindings.resolveBinding(node.name)
+ if (isEnglishFixedTranslator(node.initializer, bindings)) {
+ fixedTranslatorBindings.add(binding)
+ fixedTranslatorNames.add(node.name.text)
+ }
+ const prefix = mobileTranslatorPrefix(node.initializer, bindings)
if (prefix !== undefined) {
+ prefixedTranslatorBindings.set(binding, prefix)
prefixedTranslatorNames.set(node.name.text, prefix)
}
}
- ts.forEachChild(node, collectFixed)
+ ts.forEachChild(node, collectLocalTranslators)
}
- collectFixed(sourceFile)
+ collectLocalTranslators(sourceFile)
- return {
- declarations,
- fixedTranslatorNames,
- instanceNames,
- prefixedTranslatorNames,
- translatorNames
- }
+ return bindings
}
-export function mobileTranslationCallPrefix(call, sourceFile, bindings) {
- const callee = call.expression
- if (ts.isIdentifier(callee)) {
- if (isShadowed(callee, sourceFile, bindings.declarations)) {
- return undefined
- }
- if (
- bindings.translatorNames.has(callee.text) ||
- bindings.fixedTranslatorNames.has(callee.text)
- ) {
- return ''
- }
- return bindings.prefixedTranslatorNames.get(callee.text)
- }
- return isEnglishFixedTranslator(callee, bindings.instanceNames, sourceFile, bindings.declarations)
- ? ''
- : undefined
+export function mobileTranslationCallPrefix(call, _sourceFile, bindings) {
+ return (
+ directTranslatorPrefix(call.expression, bindings) ??
+ (isEnglishFixedTranslator(call.expression, bindings) ? '' : undefined)
+ )
}
export function isMobileTranslationCall(call, sourceFile, bindings) {
diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs
index 3787b4f7d23..b403261ac4a 100644
--- a/config/scripts/verify-mobile-localization-catalog.mjs
+++ b/config/scripts/verify-mobile-localization-catalog.mjs
@@ -12,7 +12,7 @@ import {
mobileTranslationCallPrefix
} from './mobile-localization-translation-bindings.mjs'
-const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
+const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SOURCE_RELATIVE_ROOTS = [path.join('mobile', 'app'), path.join('mobile', 'src')]
const LOCALES_RELATIVE_DIR = path.join('mobile', 'src', 'i18n', 'locales')
const MOBILE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh']
@@ -258,15 +258,16 @@ function verifyProtectedLiterals(englishValue, localeValue, locale, key) {
return issues
}
-function isLanguageNeutralValue(value) {
+function isLanguageNeutralValue(value, locale) {
const withoutPlaceholders = value.replace(PLACEHOLDER_RE, '').trim()
return (
LANGUAGE_NEUTRAL_VALUES.has(value) ||
/^(?:Alt|Ctrl|Shift)(?:\+(?:[A-Z]|Tab))?$/.test(value) ||
/^(?:Del|Enter|Esc|Ins|PgDn|PgUp|Tab)$/.test(value) ||
/^(?:F|H)\d{1,2}$/.test(value) ||
- /^(?:https?:\/\/|orca:\/\/|lin_api_)/.test(value) ||
+ /^(?:https?:\/\/|orca:\/\/|lin_api_)\S+$/.test(value) ||
/^(?:npm run dev|Linux: sudo ufw allow 6768|src\/renderer packages\/ui)$/.test(value) ||
+ (locale === 'es' && /^(?:commit|commits)$/.test(withoutPlaceholders)) ||
withoutPlaceholders === '' ||
withoutPlaceholders === '..HEAD' ||
withoutPlaceholders === '×'
@@ -395,7 +396,7 @@ function verifyLocaleEntries(englishEntries, locale, localeEntries) {
}
issues.push(...verifyProtectedLiterals(englishValue, localeValue, locale, key))
issues.push(...verifyTerminology(englishValue, localeValue, locale, key))
- if (localeValue === englishValue && !isLanguageNeutralValue(englishValue)) {
+ if (localeValue === englishValue && !isLanguageNeutralValue(englishValue, locale)) {
issues.push(`${locale}.json copies English instead of using fallback: ${key}`)
}
}
diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs
index 30d62fb2ac9..7cbdf323e26 100644
--- a/config/scripts/verify-mobile-localization-catalog.test.mjs
+++ b/config/scripts/verify-mobile-localization-catalog.test.mjs
@@ -52,7 +52,13 @@ function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
-function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) {
+function makeProject({
+ sourceText,
+ sourceFileName = 'Example.tsx',
+ catalogs,
+ nativeCatalogs,
+ appConfig
+}) {
const root = mkdtempSync(path.join(tmpdir(), 'orca-mobile-localization-'))
const appDirectory = path.join(root, 'mobile', 'app')
const sourceDirectory = path.join(root, 'mobile', 'src')
@@ -61,7 +67,7 @@ function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) {
mkdirSync(appDirectory, { recursive: true })
mkdirSync(localeDirectory, { recursive: true })
mkdirSync(nativeLocaleDirectory, { recursive: true })
- writeFileSync(path.join(appDirectory, 'Example.tsx'), sourceText, 'utf8')
+ writeFileSync(path.join(appDirectory, sourceFileName), sourceText, 'utf8')
writeJson(path.join(root, 'mobile', 'app.json'), appConfig ?? defaultAppConfig())
for (const locale of LOCALES) {
@@ -148,6 +154,55 @@ export function local(translate) {
expect(calls.map((call) => call.keys)).toEqual([['example.known']])
})
+ it('tracks function-local prefixed and fixed translators by binding', () => {
+ const sourceText = `
+import { createMobileTranslator, mobileI18n } from '@/i18n/mobile-i18n'
+export function labels() {
+ const tr = createMobileTranslator('example')
+ const fixed = mobileI18n.getFixedT('en')
+ return [tr('known'), fixed('example.fixed')]
+}
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([['example.known'], ['example.fixed']])
+ })
+
+ it('tracks instance and namespace translation calls', () => {
+ const sourceText = `
+import { mobileI18n } from '@/i18n/mobile-i18n'
+import * as i18n from '@/i18n/mobile-i18n'
+const tr = i18n.createMobileTranslator('example')
+export const labels = [
+ mobileI18n.t('example.instance'),
+ i18n.t('example.namespace'),
+ i18n.mobileI18n.t('example.namespaceInstance'),
+ tr('prefixed')
+]
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([
+ ['example.instance'],
+ ['example.namespace'],
+ ['example.namespaceInstance'],
+ ['example.prefixed']
+ ])
+ })
+
+ it('limits for-loop and catch bindings to their lexical scopes', () => {
+ const sourceText = `
+import { t } from '@/i18n/mobile-i18n'
+for (const t of callbacks) t('local-loop-call')
+export const afterLoop = t('example.afterLoop')
+try { run() } catch (t) { t('local-catch-call') }
+export const afterCatch = t('example.afterCatch')
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map((call) => call.keys)).toEqual([['example.afterLoop'], ['example.afterCatch']])
+ })
+
it('ignores unrelated local t functions', () => {
const calls = collectMobileTranslationCalls(
'/repo/mobile/app/Example.tsx',
@@ -221,6 +276,45 @@ export function local(translate) {
)
})
+ it('does not treat URL-prefixed prose as language-neutral', async () => {
+ const root = makeProject({
+ sourceText:
+ "import { t } from '@/i18n/mobile-i18n'\nexport const label = t('example.pairingHint')\n",
+ catalogs: {
+ en: { example: { pairingHint: 'orca://pair?code=... or paste the code' } },
+ ja: { example: { pairingHint: 'orca://pair?code=... or paste the code' } }
+ }
+ })
+
+ expect(await runFailedVerification(root)).toContain(
+ 'ja.json copies English instead of using fallback: example.pairingHint'
+ )
+ })
+
+ it('allows Spanish to preserve the Git term commit', async () => {
+ const root = makeProject({
+ sourceText:
+ "import { t } from '@/i18n/mobile-i18n'\nexport const label = t('example.commitCount', { commitCount: 2 })\n",
+ catalogs: {
+ en: { example: { commitCount: '{{commitCount}} commits' } },
+ es: { example: { commitCount: '{{commitCount}} commits' } }
+ }
+ })
+
+ await expect(verifyMobileCatalog(root)).resolves.toBe(0)
+ })
+
+ it('verifies translation calls in .mts source files', async () => {
+ const root = makeProject({
+ sourceFileName: 'Example.mts',
+ sourceText:
+ "import { t } from '@/i18n/mobile-i18n'\nexport const label = t('example.missing')\n",
+ catalogs: { en: { example: { known: 'Known' } } }
+ })
+
+ expect(await runFailedVerification(root)).toContain('missing English key: example.missing')
+ })
+
it('enforces Git and agent terminology', async () => {
const root = makeProject({
sourceText:
diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx
index a9b8bc49224..fa60d11f242 100644
--- a/mobile/app/h/[hostId]/tasks.tsx
+++ b/mobile/app/h/[hostId]/tasks.tsx
@@ -1631,7 +1631,7 @@ function isGitHubPrMergeBlocked(item: Extract)
}
function commentAuthor(comment: DetailComment): string {
- return comment.author ?? comment.user?.displayName ?? 'unknown'
+ return comment.author ?? comment.user?.displayName ?? tr('unknown')
}
function commentDate(value: string | undefined): string {
diff --git a/mobile/src/components/codex-reset-cleanup-warning.test.ts b/mobile/src/components/codex-reset-cleanup-warning.test.ts
new file mode 100644
index 00000000000..05754384298
--- /dev/null
+++ b/mobile/src/components/codex-reset-cleanup-warning.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { appendCodexResetCleanupWarning } from './codex-reset-cleanup-warning'
+
+describe('appendCodexResetCleanupWarning', () => {
+ it('separates a retained-journal warning from the outcome copy', () => {
+ expect(appendCodexResetCleanupWarning('Usage refreshed.', 'Retry record retained.')).toBe(
+ 'Usage refreshed.\n\nRetry record retained.'
+ )
+ })
+
+ it('leaves outcome copy unchanged without a warning', () => {
+ expect(appendCodexResetCleanupWarning('Usage refreshed.', '')).toBe('Usage refreshed.')
+ })
+})
diff --git a/mobile/src/components/codex-reset-cleanup-warning.ts b/mobile/src/components/codex-reset-cleanup-warning.ts
new file mode 100644
index 00000000000..8bce5c0e124
--- /dev/null
+++ b/mobile/src/components/codex-reset-cleanup-warning.ts
@@ -0,0 +1,3 @@
+export function appendCodexResetCleanupWarning(message: string, warning: string): string {
+ return warning ? `${message}\n\n${warning}` : message
+}
diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts
index fd0b7d4ab2d..8eeb50f3cd1 100644
--- a/mobile/src/components/mobile-rich-markdown-editor-html.ts
+++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts
@@ -1,6 +1,6 @@
import { colors } from '../theme/mobile-theme'
import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy'
-import { t } from '../i18n/mobile-i18n'
+import { getActiveMobileUiLanguageTag, t } from '../i18n/mobile-i18n'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
export function escapeInjectedJavaScriptString(value: string): string {
@@ -9,7 +9,7 @@ export function escapeInjectedJavaScriptString(value: string): string {
export function buildMobileRichMarkdownEditorHtml(): string {
return `
-
+
diff --git a/mobile/src/components/use-codex-reset-credit-action.ts b/mobile/src/components/use-codex-reset-credit-action.ts
index bc7f20bb8ce..e13412ffc25 100644
--- a/mobile/src/components/use-codex-reset-credit-action.ts
+++ b/mobile/src/components/use-codex-reset-credit-action.ts
@@ -10,6 +10,7 @@ import {
requestCodexResetCredit
} from './codex-reset-credit'
import { useCodexResetCreditCapability } from './codex-reset-credit-capability'
+import { appendCodexResetCleanupWarning } from './codex-reset-cleanup-warning'
import { t } from '@/i18n/mobile-i18n'
function describeScope(snapshot: AccountsSnapshot, scope: CodexResetCreditExpectedScope): string {
@@ -77,9 +78,10 @@ export function useCodexResetCreditAction({
: ''
Alert.alert(
t('useCodexResetCreditAction.reset'),
- t('useCodexResetCreditAction.account', {
- cleanupWarning: cleanupWarning
- })
+ appendCodexResetCleanupWarning(
+ t('useCodexResetCreditAction.account', { cleanupWarning: '' }),
+ cleanupWarning
+ )
)
return
}
@@ -87,7 +89,7 @@ export function useCodexResetCreditAction({
const cleanupWarning = result.attemptJournalRetained
? t('useCodexResetCreditAction.host')
: ''
- Alert.alert(copy.title, `${copy.message}${cleanupWarning}`)
+ Alert.alert(copy.title, appendCodexResetCleanupWarning(copy.message, cleanupWarning))
} catch (error) {
Alert.alert(
t('useCodexResetCreditAction.could'),
diff --git a/mobile/src/i18n/embedded-webview-copy.test.ts b/mobile/src/i18n/embedded-webview-copy.test.ts
index 1c72caad405..5348120a620 100644
--- a/mobile/src/i18n/embedded-webview-copy.test.ts
+++ b/mobile/src/i18n/embedded-webview-copy.test.ts
@@ -25,12 +25,14 @@ describe('embedded WebView copy', () => {
await mobileI18n.changeLanguage('es')
const editorHtml = buildMobileRichMarkdownEditorHtml()
+ expect(editorHtml).toContain('')
expect(editorHtml).toContain('data-placeholder="Empieza a escribir..."')
expect(editorHtml).toContain('window.prompt("URL del enlace")')
expect(editorHtml).toContain('window.prompt("URL de la imagen")')
expect(editorHtml).toContain(`+ "Tarea" +`)
const terminalHtml = buildTerminalWebViewHtml()
+ expect(terminalHtml).toContain('')
expect(terminalHtml).toContain('id="sel-menu-copy">Copiar')
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 `
-
+