diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs
index 9e7c73c028b..e6f28e7fa22 100644
--- a/config/scripts/audit-localization-coverage.mjs
+++ b/config/scripts/audit-localization-coverage.mjs
@@ -193,15 +193,24 @@ function isInsideLocalizationCall(node, sourceFile, mobileBindings) {
function isUnboundRenderedTranslationArgument(node, sourceFile, mobileBindings) {
const call = findAncestor(node, ts.isCallExpression)
- if (!call || !ts.isIdentifier(call.expression)) {
+ if (!call) {
return false
}
- const name = call.expression.text
- const resemblesTranslator =
- LOCALIZATION_CALL_NAMES.has(name) ||
- mobileBindings.translatorNames.has(name) ||
- mobileBindings.fixedTranslatorNames.has(name) ||
- mobileBindings.prefixedTranslatorNames.has(name)
+ let resemblesTranslator = false
+ if (ts.isIdentifier(call.expression)) {
+ const name = call.expression.text
+ resemblesTranslator =
+ LOCALIZATION_CALL_NAMES.has(name) ||
+ mobileBindings.translatorNames.has(name) ||
+ mobileBindings.fixedTranslatorNames.has(name) ||
+ mobileBindings.prefixedTranslatorNames.has(name)
+ } else if (ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 't') {
+ const rootName = expressionNameText(call.expression.expression)?.split('.')[0]
+ resemblesTranslator = Boolean(
+ rootName &&
+ (mobileBindings.namespaceNames.has(rootName) || mobileBindings.instanceNames.has(rootName))
+ )
+ }
return (
resemblesTranslator &&
!isMobileTranslationCall(call, sourceFile, mobileBindings) &&
@@ -437,7 +446,7 @@ export function collectLocalizationCandidates(filePath, sourceText, root = proce
}
const kind = mobileSource
- ? (classifyMobileStringNode(node, userVisibleErrorSource) ??
+ ? (classifyMobileStringNode(node, userVisibleErrorSource, mobileBindings) ??
(isUnboundRenderedTranslationArgument(node, sourceFile, mobileBindings)
? 'unbound-localization-call'
: undefined))
diff --git a/config/scripts/mobile-localization-candidate-rules.mjs b/config/scripts/mobile-localization-candidate-rules.mjs
index 94a891571e0..c6e25f65f1d 100644
--- a/config/scripts/mobile-localization-candidate-rules.mjs
+++ b/config/scripts/mobile-localization-candidate-rules.mjs
@@ -388,20 +388,57 @@ function isUserVisibleErrorArgument(node, userVisibleErrorSource) {
)
}
-function isNotificationChannelName(node) {
+function resolveObjectLiteral(node, bindings, seen = new Set()) {
+ if (
+ ts.isParenthesizedExpression(node) ||
+ ts.isAsExpression(node) ||
+ ts.isSatisfiesExpression(node)
+ ) {
+ return resolveObjectLiteral(node.expression, bindings, seen)
+ }
+ if (ts.isObjectLiteralExpression(node)) {
+ return node
+ }
+ if (!bindings || !ts.isIdentifier(node)) {
+ return undefined
+ }
+ const binding = bindings.resolveBinding(node)
+ if (!binding || seen.has(binding) || !ts.isVariableDeclaration(binding) || !binding.initializer) {
+ return undefined
+ }
+ seen.add(binding)
+ return resolveObjectLiteral(binding.initializer, bindings, seen)
+}
+
+function isNotificationChannelName(node, bindings) {
const property = findAncestor(node, ts.isPropertyAssignment)
const object = property?.parent
- const call = object?.parent
- return Boolean(
- property &&
- propertyNameText(property.name) === 'name' &&
- object &&
- ts.isObjectLiteralExpression(object) &&
- call &&
- ts.isCallExpression(call) &&
- call.arguments[1] === object &&
- expressionNameText(call.expression)?.endsWith('setNotificationChannelAsync')
- )
+ if (
+ !property ||
+ propertyNameText(property.name) !== 'name' ||
+ !object ||
+ !ts.isObjectLiteralExpression(object)
+ ) {
+ return false
+ }
+ let found = false
+ function visit(current) {
+ if (found) {
+ return
+ }
+ if (
+ ts.isCallExpression(current) &&
+ current.arguments[1] &&
+ expressionNameText(current.expression)?.endsWith('setNotificationChannelAsync') &&
+ resolveObjectLiteral(current.arguments[1], bindings) === object
+ ) {
+ found = true
+ return
+ }
+ ts.forEachChild(current, visit)
+ }
+ visit(node.getSourceFile())
+ return found
}
function hasDisqualifyingBinaryAncestor(node) {
@@ -448,7 +485,7 @@ function isComparisonOperand(node) {
)
}
-export function classifyMobileStringNode(node, userVisibleErrorSource) {
+export function classifyMobileStringNode(node, userVisibleErrorSource, bindings) {
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
return undefined
}
@@ -471,7 +508,7 @@ export function classifyMobileStringNode(node, userVisibleErrorSource) {
if (isComparisonOperand(node)) {
return undefined
}
- if (isNotificationChannelName(node)) {
+ if (isNotificationChannelName(node, bindings)) {
return 'notification-channel-name'
}
diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs
index 436ad7a2e22..53b750e1783 100644
--- a/config/scripts/mobile-localization-candidate-rules.test.mjs
+++ b/config/scripts/mobile-localization-candidate-rules.test.mjs
@@ -178,7 +178,8 @@ function projectRowType() {
return 'issue'
}
export function Example() {
- return <>{projectRowType() ? {lookup()} : null}>
+ const render = lookup
+ return <>{projectRowType() ? {render()} : null}>
}
`
const candidates = collectLocalizationCandidates(
@@ -192,10 +193,11 @@ export function Example() {
it('finds Android notification-channel names', () => {
const source = `
-Notifications.setNotificationChannelAsync('orca-desktop', {
+const channel = {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH
-})
+}
+Notifications.setNotificationChannelAsync('orca-desktop', channel)
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/notifications/local-notification-scheduling.ts',
@@ -224,10 +226,14 @@ export function toRow(item) {
it('recognizes aliased translators and reports calls shadowed by parameters', () => {
const source = `
import { t as translateMobile } from '@/i18n/mobile-i18n'
+import * as i18n from '@/i18n/mobile-i18n'
const localized = translateMobile('example.localized')
export function Example(translateMobile) {
return {translateMobile('Unlocalized shadowed copy')}
}
+export function MemberExample(i18n) {
+ return {i18n.t('Unlocalized shadowed member copy')}
+}
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/Example.tsx',
@@ -235,7 +241,10 @@ export function Example(translateMobile) {
'/repo'
)
- expect(candidates.map((candidate) => candidate.text)).toEqual(['Unlocalized shadowed copy'])
+ expect(candidates.map((candidate) => candidate.text)).toEqual([
+ 'Unlocalized shadowed copy',
+ 'Unlocalized shadowed member copy'
+ ])
})
it('rejects stale allowlist entries before their approval can be reused', async () => {
diff --git a/config/scripts/mobile-localization-product-glossary.mjs b/config/scripts/mobile-localization-product-glossary.mjs
new file mode 100644
index 00000000000..9de023a5054
--- /dev/null
+++ b/config/scripts/mobile-localization-product-glossary.mjs
@@ -0,0 +1,44 @@
+export const REQUIRED_TARGET_TRANSLATIONS = new Set([
+ 'pullRequest.checks.running',
+ 'pullRequest.unresolvedComments.one',
+ 'pullRequest.unresolvedComments.other'
+])
+
+export const PRODUCT_GLOSSARY = new Map([
+ [
+ 'Sparse Checkout',
+ {
+ es: 'Checkout disperso',
+ ja: 'スパースチェックアウト',
+ ko: '스파스 체크아웃',
+ zh: '稀疏检出'
+ }
+ ],
+ [
+ 'Detecting Agents',
+ {
+ es: 'Detectando agentes',
+ ja: 'エージェントを検出中',
+ ko: '에이전트 감지 중',
+ zh: '正在检测智能体'
+ }
+ ],
+ [
+ 'Loading terminal',
+ {
+ es: 'Cargando terminal',
+ ja: 'ターミナルを読み込み中',
+ ko: '터미널 불러오는 중',
+ zh: '正在加载终端'
+ }
+ ],
+ [
+ 'Open source control',
+ {
+ es: 'Abrir control de código fuente',
+ ja: 'ソース管理を開く',
+ ko: '소스 제어 열기',
+ zh: '打开源代码管理'
+ }
+ ]
+])
diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs
index 16823de3245..b346132f118 100644
--- a/config/scripts/mobile-localization-rendered-variable.mjs
+++ b/config/scripts/mobile-localization-rendered-variable.mjs
@@ -1,6 +1,10 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
+import { collectSourceBindings } from './mobile-localization-source-bindings.mjs'
+
+const SOURCE_BINDINGS = new WeakMap()
+
function isDirectDisplayExpressionParent(parent, child) {
if (
ts.isParenthesizedExpression(parent) ||
@@ -104,7 +108,7 @@ function functionName(node) {
: undefined
}
-export function directReturnFunctionName(node) {
+function directReturnFunction(node) {
let current = node
while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) {
current = current.parent
@@ -115,18 +119,102 @@ export function directReturnFunctionName(node) {
while (owner && !ts.isFunctionLike(owner)) {
owner = owner.parent
}
- return owner ? functionName(owner) : undefined
+ return owner
}
- return parent && ts.isArrowFunction(parent) && parent.body === current
- ? functionName(parent)
- : undefined
+ return parent && ts.isArrowFunction(parent) && parent.body === current ? parent : undefined
+}
+
+export function directReturnFunctionName(node) {
+ const owner = directReturnFunction(node)
+ return owner ? functionName(owner) : undefined
+}
+
+function bindingsFor(sourceFile) {
+ const cached = SOURCE_BINDINGS.get(sourceFile)
+ if (cached) {
+ return cached
+ }
+ const bindings = collectSourceBindings(sourceFile)
+ SOURCE_BINDINGS.set(sourceFile, bindings)
+ return bindings
+}
+
+function unwrapAliasExpression(node) {
+ if (
+ ts.isParenthesizedExpression(node) ||
+ ts.isAsExpression(node) ||
+ ts.isSatisfiesExpression(node)
+ ) {
+ return unwrapAliasExpression(node.expression)
+ }
+ return node
+}
+
+function renderedFunctionBindings(owner, sourceFile, bindings) {
+ const targets = new Set()
+ if (owner.name && ts.isIdentifier(owner.name)) {
+ const binding = bindings.resolveBinding(owner.name)
+ if (binding) {
+ targets.add(binding)
+ }
+ }
+ const declaration = owner.parent
+ if (
+ (ts.isArrowFunction(owner) || ts.isFunctionExpression(owner)) &&
+ ts.isVariableDeclaration(declaration) &&
+ ts.isIdentifier(declaration.name)
+ ) {
+ const binding = bindings.resolveBinding(declaration.name)
+ if (binding) {
+ targets.add(binding)
+ }
+ }
+
+ let changed = true
+ while (changed) {
+ changed = false
+ function visit(node) {
+ let alias
+ let initializer
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
+ alias = node.name
+ initializer = unwrapAliasExpression(node.initializer)
+ } else if (
+ ts.isBinaryExpression(node) &&
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ ts.isIdentifier(node.left)
+ ) {
+ alias = node.left
+ initializer = unwrapAliasExpression(node.right)
+ }
+ if (alias && initializer && ts.isIdentifier(initializer)) {
+ const sourceBinding = bindings.resolveBinding(initializer)
+ const aliasBinding = bindings.resolveBinding(alias)
+ if (
+ sourceBinding &&
+ aliasBinding &&
+ targets.has(sourceBinding) &&
+ !targets.has(aliasBinding)
+ ) {
+ targets.add(aliasBinding)
+ changed = true
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(sourceFile)
+ }
+ return targets
}
export function isReturnedByRenderedFunction(node, isRenderedExpression = isRenderedJsxExpression) {
- const name = directReturnFunctionName(node)
- if (!name) {
+ const owner = directReturnFunction(node)
+ if (!owner) {
return false
}
+ const sourceFile = node.getSourceFile()
+ const bindings = bindingsFor(sourceFile)
+ const targetBindings = renderedFunctionBindings(owner, sourceFile, bindings)
let rendered = false
function visit(current) {
if (rendered) {
@@ -135,7 +223,7 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
if (
ts.isCallExpression(current) &&
ts.isIdentifier(current.expression) &&
- current.expression.text === name &&
+ targetBindings.has(bindings.resolveBinding(current.expression)) &&
isRenderedExpression(current)
) {
rendered = true
@@ -143,7 +231,7 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
}
ts.forEachChild(current, visit)
}
- visit(node.getSourceFile())
+ visit(sourceFile)
return rendered
}
diff --git a/config/scripts/mobile-localization-source-bindings.mjs b/config/scripts/mobile-localization-source-bindings.mjs
new file mode 100644
index 00000000000..00e43e2e254
--- /dev/null
+++ b/config/scripts/mobile-localization-source-bindings.mjs
@@ -0,0 +1,122 @@
+// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
+import ts from 'typescript-api'
+
+function bindingDeclarations(name, declaration, entries = []) {
+ if (!name) {
+ return entries
+ }
+ if (ts.isIdentifier(name)) {
+ entries.push([name.text, declaration])
+ } else {
+ for (const element of name.elements) {
+ bindingDeclarations(element.name, element, entries)
+ }
+ }
+ return entries
+}
+
+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 (predicate(current)) {
+ return current
+ }
+ current = current.parent
+ }
+ return sourceFile
+}
+
+function functionScope(node, sourceFile) {
+ return nearestScope(node, sourceFile, ts.isFunctionLike)
+}
+
+function collectDeclarations(sourceFile) {
+ const declarations = new Map()
+
+ 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.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, declaration] of bindingDeclarations(node.name, node)) {
+ add(node.parent, name, declaration)
+ }
+ } else if (ts.isVariableDeclaration(node)) {
+ const declarationList = node.parent
+ const scope =
+ ts.isVariableDeclarationList(declarationList) &&
+ (declarationList.flags & ts.NodeFlags.BlockScoped) === 0
+ ? functionScope(node, sourceFile)
+ : nearestScope(node, sourceFile)
+ for (const [name, declaration] of bindingDeclarations(node.name, node)) {
+ add(scope, name, declaration)
+ }
+ } else if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
+ 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, declaration] of bindingDeclarations(
+ node.variableDeclaration.name,
+ node.variableDeclaration
+ )) {
+ add(node, name, declaration)
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+
+ visit(sourceFile)
+ return declarations
+}
+
+export function collectSourceBindings(sourceFile) {
+ const declarations = collectDeclarations(sourceFile)
+ return {
+ resolveBinding(identifier) {
+ let current = identifier.parent
+ while (current) {
+ const matches = declarations.get(current)?.get(identifier.text)
+ if (matches?.length) {
+ return matches[0]
+ }
+ if (current === sourceFile) {
+ break
+ }
+ current = current.parent
+ }
+ return undefined
+ }
+ }
+}
diff --git a/config/scripts/mobile-localization-translation-bindings.mjs b/config/scripts/mobile-localization-translation-bindings.mjs
index 8ea87e1f2e4..176dd20cf9d 100644
--- a/config/scripts/mobile-localization-translation-bindings.mjs
+++ b/config/scripts/mobile-localization-translation-bindings.mjs
@@ -1,130 +1,23 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
+import { collectSourceBindings } from './mobile-localization-source-bindings.mjs'
+
const MOBILE_I18N_MODULE_RE = /(?:^|\/)mobile-i18n$/
-function bindingNames(name, names = []) {
- if (!name) {
- return names
- }
- if (ts.isIdentifier(name)) {
- names.push(name.text)
- } else {
- for (const element of name.elements) {
- bindingNames(element.name, names)
- }
- }
- return names
-}
-
-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 (predicate(current)) {
- return current
- }
- current = current.parent
- }
- return sourceFile
-}
-
-function functionScope(node, sourceFile) {
- return nearestScope(node, sourceFile, ts.isFunctionLike)
-}
-
-function collectDeclarations(sourceFile) {
- const declarations = new Map()
-
- 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.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, node)
- }
- } else if (ts.isVariableDeclaration(node)) {
- const declarationList = node.parent
- const scope =
- ts.isVariableDeclarationList(declarationList) &&
- (declarationList.flags & ts.NodeFlags.BlockScoped) === 0
- ? functionScope(node, sourceFile)
- : nearestScope(node, sourceFile)
- for (const name of bindingNames(node.name)) {
- add(scope, name, node)
- }
- } else if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
- 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, node.variableDeclaration)
- }
- }
- ts.forEachChild(node, visit)
- }
-
- visit(sourceFile)
- return declarations
-}
-
-function resolveBinding(identifier, sourceFile, declarations) {
- let current = identifier.parent
- while (current) {
- const matches = declarations.get(current)?.get(identifier.text)
- if (matches?.length) {
- return matches[0]
- }
- if (current === sourceFile) {
- break
- }
- current = current.parent
- }
- return undefined
-}
-
function resolvesTo(identifier, bindings) {
return bindings.resolveBinding(identifier)
}
+function isMobileI18nNamespace(node, bindings) {
+ return ts.isIdentifier(node) && bindings.namespaceBindings.has(resolvesTo(node, bindings))
+}
+
function isNamespaceMember(node, memberName, bindings) {
return (
ts.isPropertyAccessExpression(node) &&
node.name.text === memberName &&
- ts.isIdentifier(node.expression) &&
- bindings.namespaceBindings.has(resolvesTo(node.expression, bindings))
+ isMobileI18nNamespace(node.expression, bindings)
)
}
@@ -135,12 +28,21 @@ function isMobileI18nInstance(node, bindings) {
return isNamespaceMember(node, 'mobileI18n', bindings)
}
+function isFixedTranslatorFactory(node, bindings) {
+ if (ts.isIdentifier(node)) {
+ return bindings.fixedFactoryBindings.has(resolvesTo(node, bindings))
+ }
+ return (
+ ts.isPropertyAccessExpression(node) &&
+ node.name.text === 'getFixedT' &&
+ isMobileI18nInstance(node.expression, bindings)
+ )
+}
+
function isEnglishFixedTranslator(node, bindings) {
return (
ts.isCallExpression(node) &&
- ts.isPropertyAccessExpression(node.expression) &&
- isMobileI18nInstance(node.expression.expression, bindings) &&
- node.expression.name.text === 'getFixedT' &&
+ isFixedTranslatorFactory(node.expression, bindings) &&
node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0]) &&
node.arguments[0].text === 'en'
@@ -188,26 +90,31 @@ function directTranslatorPrefix(callee, bindings) {
}
export function collectMobileTranslationBindings(sourceFile) {
- const declarations = collectDeclarations(sourceFile)
+ const sourceBindings = collectSourceBindings(sourceFile)
const translatorBindings = new Set()
const factoryBindings = new Set()
+ const fixedFactoryBindings = new Set()
const instanceBindings = new Set()
const namespaceBindings = new Set()
const fixedTranslatorBindings = new Set()
const prefixedTranslatorBindings = new Map()
const translatorNames = new Set()
const fixedTranslatorNames = new Set()
+ const instanceNames = new Set()
+ const namespaceNames = new Set()
const prefixedTranslatorNames = new Map()
const bindings = {
- declarations,
factoryBindings,
+ fixedFactoryBindings,
fixedTranslatorBindings,
fixedTranslatorNames,
instanceBindings,
+ instanceNames,
namespaceBindings,
+ namespaceNames,
prefixedTranslatorBindings,
prefixedTranslatorNames,
- resolveBinding: (identifier) => resolveBinding(identifier, sourceFile, declarations),
+ resolveBinding: sourceBindings.resolveBinding,
translatorBindings,
translatorNames
}
@@ -223,6 +130,7 @@ export function collectMobileTranslationBindings(sourceFile) {
const namedBindings = statement.importClause?.namedBindings
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
namespaceBindings.add(namedBindings)
+ namespaceNames.add(namedBindings.name.text)
continue
}
if (!namedBindings || !ts.isNamedImports(namedBindings)) {
@@ -237,18 +145,71 @@ export function collectMobileTranslationBindings(sourceFile) {
factoryBindings.add(element)
} else if (importedName === 'mobileI18n') {
instanceBindings.add(element)
+ instanceNames.add(element.name.text)
+ }
+ }
+ }
+
+ function addBinding(identifier, bindingSet, nameSet) {
+ const binding = bindings.resolveBinding(identifier)
+ if (!binding) {
+ return
+ }
+ bindingSet.add(binding)
+ nameSet?.add(identifier.text)
+ }
+
+ function collectDestructuredAliases(node) {
+ if (!ts.isObjectBindingPattern(node.name) || !node.initializer) {
+ return
+ }
+ const namespaceSource = isMobileI18nNamespace(node.initializer, bindings)
+ const instanceSource = isMobileI18nInstance(node.initializer, bindings)
+ if (!namespaceSource && !instanceSource) {
+ return
+ }
+ for (const element of node.name.elements) {
+ if (!ts.isIdentifier(element.name) || element.dotDotDotToken) {
+ continue
+ }
+ const propertyName = element.propertyName ?? element.name
+ if (!ts.isIdentifier(propertyName) && !ts.isStringLiteralLike(propertyName)) {
+ continue
+ }
+ if (propertyName.text === 't') {
+ addBinding(element.name, translatorBindings, translatorNames)
+ } else if (namespaceSource && propertyName.text === 'createMobileTranslator') {
+ addBinding(element.name, factoryBindings)
+ } else if (namespaceSource && propertyName.text === 'mobileI18n') {
+ addBinding(element.name, instanceBindings, instanceNames)
+ } else if (instanceSource && propertyName.text === 'getFixedT') {
+ addBinding(element.name, fixedFactoryBindings)
}
}
}
function collectLocalTranslators(node) {
+ if (ts.isVariableDeclaration(node)) {
+ collectDestructuredAliases(node)
+ }
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
const binding = bindings.resolveBinding(node.name)
- if (isTranslatorFactory(node.initializer, bindings)) {
+ if (!binding) {
+ ts.forEachChild(node, collectLocalTranslators)
+ return
+ }
+ if (isMobileI18nNamespace(node.initializer, bindings)) {
+ namespaceBindings.add(binding)
+ namespaceNames.add(node.name.text)
+ } else if (isTranslatorFactory(node.initializer, bindings)) {
factoryBindings.add(binding)
}
+ if (isFixedTranslatorFactory(node.initializer, bindings)) {
+ fixedFactoryBindings.add(binding)
+ }
if (isMobileI18nInstance(node.initializer, bindings)) {
instanceBindings.add(binding)
+ instanceNames.add(node.name.text)
}
if (isEnglishFixedTranslator(node.initializer, bindings)) {
fixedTranslatorBindings.add(binding)
diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs
index 6d61156b953..197a430ed72 100644
--- a/config/scripts/verify-mobile-localization-catalog.mjs
+++ b/config/scripts/verify-mobile-localization-catalog.mjs
@@ -11,6 +11,10 @@ import {
isMobileTranslationCall,
mobileTranslationCallPrefix
} from './mobile-localization-translation-bindings.mjs'
+import {
+ PRODUCT_GLOSSARY,
+ REQUIRED_TARGET_TRANSLATIONS
+} from './mobile-localization-product-glossary.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SOURCE_RELATIVE_ROOTS = [path.join('mobile', 'app'), path.join('mobile', 'src')]
@@ -249,11 +253,11 @@ function occurrenceCount(value, literal) {
return value.split(literal).length - 1
}
-function verifyProtectedLiterals(englishValue, localeValue, locale, key) {
+function verifyProtectedLiterals(englishValue, localeValue, catalogName, key) {
const issues = []
for (const literal of PROTECTED_LITERALS) {
if (occurrenceCount(localeValue, literal) < occurrenceCount(englishValue, literal)) {
- issues.push(`${locale}.json protected literal mismatch: ${key} must preserve ${literal}`)
+ issues.push(`${catalogName} protected literal mismatch: ${key} must preserve ${literal}`)
}
}
return issues
@@ -275,20 +279,24 @@ function isLanguageNeutralValue(value, locale) {
)
}
-function verifyTerminology(englishValue, localeValue, locale, key) {
+function verifyTerminology(englishValue, localeValue, locale, catalogName, key) {
const issues = []
if (
locale === 'es' &&
/\bbranch(?:es)?\b/i.test(englishValue) &&
/\bsucursal(?:es)?\b/i.test(localeValue)
) {
- issues.push(`${locale}.json Git terminology mismatch: ${key} translates branch as sucursal`)
+ issues.push(`${catalogName} Git terminology mismatch: ${key} translates branch as sucursal`)
}
if (locale === 'zh' && /\bhost\b/i.test(englishValue) && localeValue.includes('主人')) {
- issues.push(`${locale}.json host terminology mismatch: ${key} uses 主人`)
+ issues.push(`${catalogName} host terminology mismatch: ${key} uses 主人`)
}
if (locale === 'zh' && /\bagent/i.test(englishValue) && localeValue.includes('检测剂')) {
- issues.push(`${locale}.json agent terminology mismatch: ${key} uses 检测剂`)
+ issues.push(`${catalogName} agent terminology mismatch: ${key} uses 检测剂`)
+ }
+ const expectedValue = PRODUCT_GLOSSARY.get(englishValue)?.[locale]
+ if (expectedValue && localeValue !== expectedValue) {
+ issues.push(`${catalogName} product terminology mismatch: ${key} must use ${expectedValue}`)
}
return issues
}
@@ -379,29 +387,41 @@ function verifyEnglishEntries(calls, englishEntries) {
return issues
}
-function verifyLocaleEntries(englishEntries, locale, localeEntries) {
+function verifyLocaleEntries(
+ englishEntries,
+ locale,
+ localeEntries,
+ catalogName = `${locale}.json`,
+ requiredTranslations = REQUIRED_TARGET_TRANSLATIONS
+) {
const issues = []
const localeKeys = [...localeEntries.keys()].sort()
+ for (const key of requiredTranslations) {
+ if (englishEntries.has(key) && !localeEntries.has(key)) {
+ issues.push(`${catalogName} missing required translation: ${key}`)
+ }
+ }
+
for (const key of localeKeys) {
const englishValue = englishEntries.get(key)
if (englishValue === undefined) {
- issues.push(`${locale}.json has extra key: ${key}`)
+ issues.push(`${catalogName} has extra key: ${key}`)
continue
}
const englishPlaceholders = collectPlaceholderNames(englishValue)
const localeValue = localeEntries.get(key)
const localePlaceholders = collectPlaceholderNames(localeValue)
if (!sameValues(englishPlaceholders, localePlaceholders)) {
- issues.push(`${locale}.json placeholder mismatch: ${key}`)
+ issues.push(`${catalogName} placeholder mismatch: ${key}`)
}
if (EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) && localeValue !== englishValue) {
- issues.push(`${locale}.json must preserve language-neutral value: ${key}`)
+ issues.push(`${catalogName} must preserve language-neutral value: ${key}`)
}
- issues.push(...verifyProtectedLiterals(englishValue, localeValue, locale, key))
- issues.push(...verifyTerminology(englishValue, localeValue, locale, key))
+ issues.push(...verifyProtectedLiterals(englishValue, localeValue, catalogName, key))
+ issues.push(...verifyTerminology(englishValue, localeValue, locale, catalogName, key))
if (localeValue === englishValue && !isLanguageNeutralValue(englishValue, locale)) {
- issues.push(`${locale}.json copies English instead of using fallback: ${key}`)
+ issues.push(`${catalogName} copies English instead of using fallback: ${key}`)
}
}
@@ -516,6 +536,21 @@ async function verifyNativeCatalogs(root) {
const englishEntries = nativeCatalogs.get('en')
if (englishEntries) {
issues.push(...verifyNativeFallbacks(expoConfig, englishEntries))
+ for (const locale of NATIVE_LOCALES.slice(1)) {
+ const localeEntries = nativeCatalogs.get(locale)
+ if (localeEntries) {
+ const runtimeLocale = locale === 'zh-Hans' ? 'zh' : locale
+ issues.push(
+ ...verifyLocaleEntries(
+ englishEntries,
+ runtimeLocale,
+ localeEntries,
+ `mobile/locales/${locale}.json`,
+ []
+ )
+ )
+ }
+ }
}
return issues
}
diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs
index e30a4889521..6bb4417de01 100644
--- a/config/scripts/verify-mobile-localization-catalog.test.mjs
+++ b/config/scripts/verify-mobile-localization-catalog.test.mjs
@@ -22,6 +22,22 @@ const NATIVE_CATALOG = {
android: { app_name: 'Orca' }
}
+function nativeCatalogFor(locale) {
+ if (locale === 'en') {
+ return NATIVE_CATALOG
+ }
+ return {
+ ios: {
+ CFBundleDisplayName: 'Orca',
+ NSCameraUsageDescription: `${locale} Camera fallback`,
+ NSLocalNetworkUsageDescription: `${locale} Network fallback`,
+ NSMicrophoneUsageDescription: `${locale} Microphone fallback`,
+ NSPhotoLibraryUsageDescription: `${locale} Photo fallback`
+ },
+ android: { app_name: 'Orca' }
+ }
+}
+
function defaultAppConfig() {
return {
expo: {
@@ -79,7 +95,7 @@ function makeProject({
for (const locale of NATIVE_LOCALES) {
writeJson(
path.join(nativeLocaleDirectory, `${locale}.json`),
- nativeCatalogs?.[locale] ?? NATIVE_CATALOG
+ nativeCatalogs?.[locale] ?? nativeCatalogFor(locale)
)
}
return root
@@ -214,6 +230,35 @@ export const labels = [
])
})
+ it('tracks namespace aliases and destructured translation bindings', () => {
+ const sourceText = `
+import { mobileI18n } from '@/i18n/mobile-i18n'
+import * as i18n from '@/i18n/mobile-i18n'
+const localI18n = i18n
+const { t: instanceT, getFixedT } = mobileI18n
+const { t: namespaceT, createMobileTranslator: make, mobileI18n: aliasedInstance } = localI18n
+const { t: aliasedInstanceT } = aliasedInstance
+const prefixed = make('example')
+const fixed = getFixedT('en')
+export const labels = [
+ instanceT('example.instance', { name: 'Orca' }),
+ namespaceT('example.namespace'),
+ aliasedInstanceT('example.aliasedInstance'),
+ prefixed('prefixed'),
+ fixed('example.fixed')
+]
+`
+ const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
+
+ expect(calls.map(({ keys, options }) => ({ keys, options: options.names }))).toEqual([
+ { keys: ['example.instance'], options: ['name'] },
+ { keys: ['example.namespace'], options: [] },
+ { keys: ['example.aliasedInstance'], options: [] },
+ { keys: ['example.prefixed'], options: [] },
+ { keys: ['example.fixed'], options: [] }
+ ])
+ })
+
it('limits for-loop and catch bindings to their lexical scopes', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
@@ -249,7 +294,7 @@ export const afterCatch = t('example.afterCatch')
it('requires call options to exactly match English placeholders', async () => {
const root = makeProject({
sourceText:
- "import { t } from '@/i18n/mobile-i18n'\nexport const label = t('m.greeting', { value: 'Orca' })\n",
+ "import { mobileI18n } from '@/i18n/mobile-i18n'\nconst { t: translate } = mobileI18n\nexport const label = translate('m.greeting', { value: 'Orca' })\n",
catalogs: { en: { m: { greeting: 'Hello {{name}}' } } }
})
@@ -272,6 +317,36 @@ export const afterCatch = t('example.afterCatch')
await expect(verifyMobileCatalog(root)).resolves.toBe(0)
})
+ it('requires every target locale to translate composed PR status fragments', async () => {
+ const root = makeProject({
+ sourceText: `
+import { t } from '@/i18n/mobile-i18n'
+export const labels = [
+ t('pullRequest.checks.running', { count: 1 }),
+ t('pullRequest.unresolvedComments.one', { count: 1 }),
+ t('pullRequest.unresolvedComments.other', { count: 2 })
+]
+`,
+ catalogs: {
+ en: {
+ pullRequest: {
+ checks: { running: '{{count}} running' },
+ unresolvedComments: {
+ one: ', {{count}} unresolved comment',
+ other: ', {{count}} unresolved comments'
+ }
+ }
+ }
+ }
+ })
+
+ const report = await runFailedVerification(root)
+ expect(report).toContain('es.json missing required translation: pullRequest.checks.running')
+ expect(report).toContain(
+ 'zh.json missing required translation: pullRequest.unresolvedComments.other'
+ )
+ })
+
it('rejects opaque IDs, positional placeholders, and orphaned English keys', async () => {
const root = makeProject({
sourceText:
@@ -371,6 +446,46 @@ export const afterCatch = t('example.afterCatch')
expect(report).toContain('uses 检测剂')
})
+ it('enforces the mobile Git and product glossary', async () => {
+ const root = makeProject({
+ sourceText: `
+import { t } from '@/i18n/mobile-i18n'
+export const labels = [
+ t('example.sparse'),
+ t('example.detecting'),
+ t('example.loadingTerminal'),
+ t('example.openSource')
+]
+`,
+ catalogs: {
+ en: {
+ example: {
+ sparse: 'Sparse Checkout',
+ detecting: 'Detecting Agents',
+ loadingTerminal: 'Loading terminal',
+ openSource: 'Open source control'
+ }
+ },
+ es: {
+ example: {
+ sparse: 'Pago escaso',
+ detecting: 'Agents de detección',
+ loadingTerminal: 'terminal de carga',
+ openSource: 'Control de código abierto'
+ }
+ },
+ ja: { example: { detecting: '検出剤' } },
+ zh: { example: { sparse: '稀疏结帐', loadingTerminal: '装货码头' } }
+ }
+ })
+
+ const report = await runFailedVerification(root)
+ expect(report).toContain('es.json product terminology mismatch: example.sparse')
+ expect(report).toContain('ja.json product terminology mismatch: example.detecting')
+ expect(report).toContain('zh.json product terminology mismatch: example.loadingTerminal')
+ expect(report).toContain('es.json product terminology mismatch: example.openSource')
+ })
+
it('validates placeholders and extra keys in present translations', async () => {
const en = { m: { greeting: 'Hello {{name}}' } }
const root = makeProject({
@@ -453,4 +568,31 @@ export const afterCatch = t('example.afterCatch')
'mobile/app.json English native fallback mismatch: ios.NSCameraUsageDescription'
)
})
+
+ it('checks native translations for copied English, literals, and placeholders', async () => {
+ const englishNative = structuredClone(NATIVE_CATALOG)
+ englishNative.ios.NSCameraUsageDescription = 'Allow Orca to use {{resource}}'
+ const spanishNative = nativeCatalogFor('es')
+ spanishNative.ios.NSCameraUsageDescription = 'Permite que Orka use {{wrong}}'
+ spanishNative.ios.NSLocalNetworkUsageDescription = 'Network fallback'
+ const appConfig = defaultAppConfig()
+ appConfig.expo.plugins[1][1].cameraPermission = englishNative.ios.NSCameraUsageDescription
+ const root = makeProject({
+ sourceText: "export const label = 'not rendered'\n",
+ catalogs: { en: { m: { label: 'Visible' } } },
+ nativeCatalogs: { en: englishNative, es: spanishNative },
+ appConfig
+ })
+
+ const report = await runFailedVerification(root)
+ expect(report).toContain(
+ 'mobile/locales/es.json placeholder mismatch: ios.NSCameraUsageDescription'
+ )
+ expect(report).toContain(
+ 'mobile/locales/es.json protected literal mismatch: ios.NSCameraUsageDescription must preserve Orca'
+ )
+ expect(report).toContain(
+ 'mobile/locales/es.json copies English instead of using fallback: ios.NSLocalNetworkUsageDescription'
+ )
+ })
})
diff --git a/mobile/src/components/VoiceModelList.tsx b/mobile/src/components/VoiceModelList.tsx
index 1832594848c..370607f8e11 100644
--- a/mobile/src/components/VoiceModelList.tsx
+++ b/mobile/src/components/VoiceModelList.tsx
@@ -36,7 +36,7 @@ function modelMeta(model: MobileSpeechModel): string {
if (model.status === 'extracting') {
return [formatSize(model.sizeBytes), t('mobileDictationSetupSheet.extracting')]
.filter(Boolean)
- .join(' · ')
+ .join(' ')
}
return formatSize(model.sizeBytes)
}
diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json
index 4926a8a487d..ffcb2f075bd 100644
--- a/mobile/src/i18n/locales/es.json
+++ b/mobile/src/i18n/locales/es.json
@@ -152,7 +152,7 @@
"newPreset": "Nuevo preset",
"useWhole": "Utilice todo el repositorio",
"full": "Checkout completo",
- "sparse": "Pago escaso",
+ "sparse": "Checkout disperso",
"noBranches": "Ninguna rama coincide.",
"useRepositorys": "Utilice la base configurada de este repositorio",
"defaultBranch": "Rama predeterminada",
@@ -610,7 +610,7 @@
"back": "Volver a los árboles de trabajo",
"agent": "Presets de Agent no disponibles",
"noEnabled": "No hay Agents habilitados",
- "detecting": "Agents de detección",
+ "detecting": "Detectando agentes",
"browserPage": "La página del navegador aún no está disponible.",
"desktopUpdateRequiredMobile": "Se requiere actualización de escritorio para la transmisión del navegador móvil",
"edit": "Edite este comando rápido antes de ejecutarlo",
@@ -659,11 +659,11 @@
"switchDesktop": "Cambiar al modo de escritorio",
"hides": "Oculta el teclado del software y mantiene abierta la sesión actual del terminal.",
"dismissKeyboard": "Descartar teclado",
- "loadingTerminal": "terminal de carga",
+ "loadingTerminal": "Cargando terminal",
"checking": "Comprobando las capacidades del escritorio: inténtalo de nuevo en un momento",
"desktopUpdateRequiredQuick": "Se requiere actualización de escritorio para comandos rápidos",
"newTab": "Nueva pestaña",
- "openSource": "Control de código abierto",
+ "openSource": "Abrir control de código fuente",
"openFile": "Abrir explorador de archivos",
"couldNotSend": "No se pudieron enviar notas",
"notesSent": "Notas enviadas",
@@ -2478,6 +2478,13 @@
}
},
"pullRequest": {
+ "checks": {
+ "running": "{{count}} en curso"
+ },
+ "unresolvedComments": {
+ "one": ", {{count}} comentario sin resolver",
+ "other": ", {{count}} comentarios sin resolver"
+ },
"conflicts": {
"fileDetailsUnavailable": "Los detalles de los archivos en conflicto no están disponibles",
"refreshing": "Actualizando los detalles de los conflictos…",
diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json
index 8d385d322c8..500c120d992 100644
--- a/mobile/src/i18n/locales/ja.json
+++ b/mobile/src/i18n/locales/ja.json
@@ -624,7 +624,7 @@
"back": "ワークツリーに戻る",
"agent": "Agent のプリセットは利用できません",
"noEnabled": "有効な Agents がありません",
- "detecting": "検出剤",
+ "detecting": "エージェントを検出中",
"browserPage": "ブラウザ ページはまだ利用できません。",
"desktopUpdateRequiredMobile": "モバイルブラウザストリーミングにはデスクトップのアップデートが必要です",
"edit": "このクイックコマンドを実行する前に編集してください",
@@ -673,11 +673,11 @@
"switchDesktop": "デスクトップモードに切り替える",
"hides": "ソフトウェア キーボードを非表示にし、現在の terminal セッションを開いたままにします。",
"dismissKeyboard": "キーボードを閉じる",
- "loadingTerminal": "ローディング terminal",
+ "loadingTerminal": "ターミナルを読み込み中",
"checking": "デスクトップの機能をチェックしています - しばらくしてからもう一度お試しください",
"desktopUpdateRequiredQuick": "クイックコマンドを実行するにはデスクトップのアップデートが必要です",
"newTab": "新規タブ",
- "openSource": "オープンソース管理",
+ "openSource": "ソース管理を開く",
"openFile": "ファイルエクスプローラーを開く",
"couldNotSend": "メモを送信できませんでした",
"notesSent": "送信されたメモ",
@@ -2521,6 +2521,13 @@
}
},
"pullRequest": {
+ "checks": {
+ "running": "{{count}} 件実行中"
+ },
+ "unresolvedComments": {
+ "one": "、未解決のコメント {{count}} 件",
+ "other": "、未解決のコメント {{count}} 件"
+ },
"conflicts": {
"fileDetailsUnavailable": "競合ファイルの詳細を取得できません",
"refreshing": "競合の詳細を更新しています…",
diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json
index a8c0adb49f8..a541c92ead0 100644
--- a/mobile/src/i18n/locales/ko.json
+++ b/mobile/src/i18n/locales/ko.json
@@ -624,7 +624,7 @@
"back": "작업 트리로 돌아가기",
"agent": "Agent 사전 설정을 사용할 수 없음",
"noEnabled": "활성화된 Agents 없음",
- "detecting": "Agents 감지",
+ "detecting": "에이전트 감지 중",
"browserPage": "브라우저 페이지는 아직 사용할 수 없습니다.",
"desktopUpdateRequiredMobile": "모바일 브라우저 스트리밍을 위해서는 데스크톱 업데이트가 필요합니다.",
"edit": "실행하기 전에 이 빠른 명령을 편집하세요",
@@ -673,11 +673,11 @@
"switchDesktop": "데스크톱 모드로 전환",
"hides": "소프트웨어 키보드를 숨기고 현재 terminal 세션을 열어 둡니다.",
"dismissKeyboard": "키보드 닫기",
- "loadingTerminal": "로딩 terminal",
+ "loadingTerminal": "터미널 불러오는 중",
"checking": "데스크톱 기능 확인 중 — 잠시 후 다시 시도하세요",
"desktopUpdateRequiredQuick": "빠른 명령을 사용하려면 데스크탑 업데이트가 필요합니다.",
"newTab": "새 탭",
- "openSource": "오픈소스 제어",
+ "openSource": "소스 제어 열기",
"openFile": "파일 탐색기 열기",
"couldNotSend": "메모를 보낼 수 없습니다.",
"notesSent": "보낸 메모",
@@ -2516,6 +2516,13 @@
}
},
"pullRequest": {
+ "checks": {
+ "running": "{{count}}개 실행 중"
+ },
+ "unresolvedComments": {
+ "one": ", 해결되지 않은 댓글 {{count}}개",
+ "other": ", 해결되지 않은 댓글 {{count}}개"
+ },
"conflicts": {
"fileDetailsUnavailable": "충돌 파일 세부 정보를 사용할 수 없습니다",
"refreshing": "충돌 세부 정보 새로 고치는 중…",
diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json
index 77433a3ea2c..dbbb2fd2189 100644
--- a/mobile/src/i18n/locales/zh.json
+++ b/mobile/src/i18n/locales/zh.json
@@ -157,7 +157,7 @@
"newPreset": "新预设",
"useWhole": "使用整个存储库",
"full": "完整检出",
- "sparse": "稀疏结帐",
+ "sparse": "稀疏检出",
"noBranches": "没有分支匹配。",
"useRepositorys": "使用此存储库的配置基础",
"defaultBranch": "默认分支",
@@ -673,11 +673,11 @@
"switchDesktop": "切换到桌面模式",
"hides": "隐藏软件键盘并保持当前 terminal 会话打开。",
"dismissKeyboard": "关闭键盘",
- "loadingTerminal": "装货码头",
+ "loadingTerminal": "正在加载终端",
"checking": "检查桌面功能 - 稍后重试",
"desktopUpdateRequiredQuick": "快速命令需要更新桌面",
"newTab": "新标签页",
- "openSource": "开源控制",
+ "openSource": "打开源代码管理",
"openFile": "打开文件资源管理器",
"couldNotSend": "无法发送笔记",
"notesSent": "已发送注释",
@@ -2529,6 +2529,13 @@
}
},
"pullRequest": {
+ "checks": {
+ "running": "{{count}} 项运行中"
+ },
+ "unresolvedComments": {
+ "one": ",{{count}} 条未解决评论",
+ "other": ",{{count}} 条未解决评论"
+ },
"conflicts": {
"fileDetailsUnavailable": "无法获取冲突文件详情",
"refreshing": "正在刷新冲突详情…",