fix(mobile): harden localization review gates

This commit is contained in:
Brennan Benson
2026-08-01 09:42:47 -07:00
parent b5d04b3b24
commit fc1e03ffa7
14 changed files with 938 additions and 288 deletions
@@ -8,6 +8,8 @@ import {
isReturnedByRenderedFunction
} from './mobile-localization-rendered-variable.mjs'
const NOTIFICATION_CHANNEL_NAME_NODES = new WeakMap()
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
'ariaLabel',
'aria-label',
@@ -388,57 +390,80 @@ function isUserVisibleErrorArgument(node, userVisibleErrorSource) {
)
}
function resolveObjectLiteral(node, bindings, seen = new Set()) {
function resolveBoundInitializer(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
return resolveBoundInitializer(node.expression, bindings, seen)
}
if (!bindings || !ts.isIdentifier(node)) {
return undefined
return node
}
const binding = bindings.resolveBinding(node)
if (!binding || seen.has(binding) || !ts.isVariableDeclaration(binding) || !binding.initializer) {
return undefined
return node
}
seen.add(binding)
return resolveObjectLiteral(binding.initializer, bindings, seen)
return resolveBoundInitializer(binding.initializer, bindings, seen)
}
function objectPropertyValues(node, propertyName, bindings, seen = new Set()) {
const expression = resolveBoundInitializer(node, bindings, seen)
if (!ts.isObjectLiteralExpression(expression)) {
return []
}
return expression.properties.flatMap((property) => {
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propertyName) {
return [property.initializer]
}
if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) {
return [property.name]
}
return ts.isSpreadAssignment(property)
? objectPropertyValues(property.expression, propertyName, bindings, new Set(seen))
: []
})
}
function collectBoundStringNodes(expression, bindings, nodes, seen = new Set()) {
const resolved = resolveBoundInitializer(expression, bindings, seen)
if (ts.isStringLiteralLike(resolved) || ts.isTemplateExpression(resolved)) {
nodes.add(resolved)
return
}
ts.forEachChild(resolved, (child) =>
collectBoundStringNodes(child, bindings, nodes, new Set(seen))
)
}
function isNotificationChannelName(node, bindings) {
const property = findAncestor(node, ts.isPropertyAssignment)
const object = property?.parent
if (
!property ||
propertyNameText(property.name) !== 'name' ||
!object ||
!ts.isObjectLiteralExpression(object)
) {
return false
const sourceFile = node.getSourceFile()
const cached = NOTIFICATION_CHANNEL_NAME_NODES.get(sourceFile)
if (cached) {
return cached.has(node)
}
let found = false
const nodes = new Set()
function visit(current) {
if (found) {
return
}
if (
ts.isCallExpression(current) &&
current.arguments[1] &&
expressionNameText(current.expression)?.endsWith('setNotificationChannelAsync') &&
resolveObjectLiteral(current.arguments[1], bindings) === object
expressionNameText(current.expression)?.endsWith('setNotificationChannelAsync')
) {
found = true
return
for (const value of objectPropertyValues(current.arguments[1], 'name', bindings)) {
collectBoundStringNodes(value, bindings, nodes)
}
}
ts.forEachChild(current, visit)
}
visit(node.getSourceFile())
return found
visit(sourceFile)
NOTIFICATION_CHANNEL_NAME_NODES.set(sourceFile, nodes)
return nodes.has(node)
}
function isRenderedMobileExpression(expression) {
return isRenderedJsxExpression(expression) || isAssignedToRenderedVariable(expression)
}
function hasDisqualifyingBinaryAncestor(node) {
@@ -581,14 +606,7 @@ export function classifyMobileStringNode(node, userVisibleErrorSource, bindings)
) {
return 'user-visible-return'
}
if (
returnFunctionName &&
isReturnedByRenderedFunction(
node,
(expression) =>
isRenderedJsxExpression(expression) || isAssignedToRenderedVariable(expression)
)
) {
if (returnFunctionName && isReturnedByRenderedFunction(node, isRenderedMobileExpression)) {
return 'rendered-function-return'
}
if (isUserVisibleErrorArgument(node, userVisibleErrorSource)) {
@@ -191,6 +191,42 @@ export function Example() {
expect(candidates.map((candidate) => candidate.text)).toEqual(['Extracting model'])
})
it('finds copy returned through a rendered helper object', () => {
const source = `
function lookup() {
return 'Extracting object model'
}
export function Example() {
const renderers = { lookup }
return <Text>{renderers.lookup()}</Text>
}
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/Example.tsx',
source,
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual(['Extracting object model'])
})
it('matches rendered variables by binding instead of identifier text', () => {
const source = `
const value = 'internal mode name'
export function Example() {
const value = 'Visible nested value'
return <Text>{value}</Text>
}
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/Example.tsx',
source,
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual(['Visible nested value'])
})
it('finds Android notification-channel names', () => {
const source = `
const channel = {
@@ -208,6 +244,45 @@ Notifications.setNotificationChannelAsync('orca-desktop', channel)
expect(candidates.map((candidate) => candidate.text)).toEqual(['Desktop Notifications'])
})
it('finds bound and spread Android notification-channel names', () => {
const source = `
const channelName = 'Desktop Notifications'
const channel = { name: channelName }
const baseChannel = { name: 'Background Notifications' }
const spreadChannel = { ...baseChannel }
Notifications.setNotificationChannelAsync('orca-desktop', channel)
Notifications.setNotificationChannelAsync('orca-background', spreadChannel)
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/notifications/local-notification-scheduling.ts',
source,
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual([
'Desktop Notifications',
'Background Notifications'
])
})
it('reports raw copy from a namespace-local translator', () => {
const source = `
import { t } from '@/i18n/mobile-i18n'
namespace Local {
var t = (value) => value
export const label = <Text>{t('Namespace raw copy')}</Text>
}
export const translated = t('example.translated')
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/Example.tsx',
source,
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual(['Namespace raw copy'])
})
it('finds user-visible subject fallbacks in returned rows', () => {
const source = `
export function toRow(item) {
@@ -4,6 +4,151 @@ export const REQUIRED_TARGET_TRANSLATIONS = new Set([
'pullRequest.unresolvedComments.other'
])
export const EXACT_LANGUAGE_NEUTRAL_KEYS = new Set([
'mobileRichMarkdownEditor.h2',
'terminalAccessoryKeyCatalog.del',
'terminalAccessoryKeyCatalog.esc',
'terminalAccessoryKeyCatalog.shiftPlus',
'terminalAccessoryKeys.alt',
'terminalAccessoryKeys.ctrl',
'terminalAccessoryKeys.ins',
'terminalAccessoryKeys.pgDn',
'terminalAccessoryKeys.pgUp',
'terminalAccessoryKeys.shift'
])
export const REVIEWED_KEY_TRANSLATIONS = new Map([
[
'task.gitHubReportsMergeConflictsOpen',
{ es: 'GitHub informa de conflictos de fusión. Abre GitHub para continuar.' }
],
['task.viewOrder', { es: 'Orden de vista', ja: '表示順', ko: '보기 순서', zh: '视图顺序' }],
['workspaceListPickerOptions.server', { ko: '서버 순서' }],
[
'task.orderLinearOrder',
{
ja: '順序: {{linearOrderLabel}}',
ko: '순서: {{linearOrderLabel}}',
zh: '顺序:{{linearOrderLabel}}'
}
],
['terminalAccessoryKeyCatalog.escape', { ja: 'Escapeキー', ko: 'Esc 키', zh: 'Esc 键' }],
['terminalAccessoryKeyCatalog.enter', { ja: 'Enterキー', ko: 'Enter 키', zh: 'Enter 键' }],
['terminalAccessoryKeyCatalog.space', { ja: 'スペース', ko: '스페이스', zh: '空格键' }],
['customKeyModal.key', { es: 'Tecla', ja: 'キー', zh: '按键' }],
['customKeyModal.pick', { es: 'Elige una tecla', ja: 'キーを選択してください', zh: '选择按键' }],
[
'voiceSettings.togglePress',
{
ko: '토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 길게 누르기: 누르고 있는 동안 받아씁니다.'
}
],
[
'mobileHostedReviewCreateIntent.force',
{
es: 'Haciendo push forzado con lease...',
ja: 'リースを使用して強制的にプッシュします...',
ko: '리스를 사용해 강제 푸시 중...',
zh: '正在使用租约强制推送...'
}
],
[
'mobileHostedReviewCreateIntent.pushing',
{
es: 'Haciendo push de commits...',
ja: 'commits をプッシュしています...',
ko: 'commits 푸시 중...',
zh: '正在推送 commits...'
}
],
[
'mobilePrCreate.push',
{
es: 'Haz push de la rama base antes de crear un {{reviewType}}.',
ja: '{{reviewType}} を作成する前にベース ブランチをプッシュしてください。',
ko: '{{reviewType}}을 생성하기 전에 기본 브랜치를 푸시하세요.',
zh: '在创建 {{reviewType}} 之前推送基础分支。'
}
],
[
'mobileSourceControlPrimaryAction.forcePushLease',
{
es: 'Haz un push forzado con lease para actualizar la rama remota.',
ja: 'リモート ブランチを更新するには、リースを使用して強制的にプッシュします。',
ko: '원격 브랜치를 업데이트하려면 리스를 사용해 강제 푸시하세요.',
zh: '使用租约强制推送以更新远程分支。'
}
],
[
'mobileSourceControlPrimaryAction.forcePushProgress',
{
es: 'Push forzado en curso.',
ja: '強制プッシュが進行中です。',
ko: '강제 푸시가 진행 중입니다.',
zh: '正在进行强制推送。'
}
],
[
'mobileSourceControlPrimaryAction.pullCommitCountCommit',
{
es: 'Haz pull de {{commitCount}} commit.',
ja: '{{commitCount}} commit をプルします。',
ko: '{{commitCount}}개 commit을 풀합니다.',
zh: '拉取 {{commitCount}} commit。'
}
],
[
'mobileSourceControlPrimaryAction.pullCommitCountCommits',
{
es: 'Haz pull de {{commitCount}} commits.',
ja: '{{commitCount}} commits をプルします。',
ko: '{{commitCount}}개 commits을 풀합니다.',
zh: '拉取 {{commitCount}} commits。'
}
],
[
'mobileSourceControlPrimaryAction.pushCommitCountCommit',
{
es: 'Haz push de {{commitCount}} commit.',
ja: '{{commitCount}} commit をプッシュします。',
ko: '{{commitCount}} commit을 푸시합니다.',
zh: '推送 {{commitCount}} commit。'
}
],
[
'mobileSourceControlPrimaryAction.pushCommitCountCommits',
{
es: 'Haz push de {{commitCount}} commits.',
ja: '{{commitCount}} commits をプッシュします。',
ko: '{{commitCount}} commits을 푸시합니다.',
zh: '推送 {{commitCount}} commits。'
}
],
[
'mobilePrComposeForm.pushCreateReview',
{
es: 'Hacer push y crear {{reviewType}}',
ja: 'プッシュして {{reviewType}} を作成',
ko: '푸시하고 {{reviewType}} 생성',
zh: '推送并创建 {{reviewType}}'
}
],
[
'mobilePrComposeForm.pushCreateDraft',
{
es: 'Hacer push y crear borrador de {{reviewType}}',
ja: 'プッシュしてドラフト {{reviewType}} を作成',
ko: '푸시하고 초안 {{reviewType}} 생성',
zh: '推送并创建草稿 {{reviewType}}'
}
],
[
'mobileOnboarding.notification',
{ ja: '通知設定を更新できませんでした。もう一度お試しください。' }
],
['mobileOnboarding.your', { ja: '選択内容を保存できませんでした。もう一度お試しください。' }]
])
export const PRODUCT_GLOSSARY = new Map([
[
'Sparse Checkout',
@@ -40,5 +185,147 @@ export const PRODUCT_GLOSSARY = new Map([
ko: '소스 제어 열기',
zh: '打开源代码管理'
}
],
[
'{{pipelineJobCount}} jobs',
{
es: '{{pipelineJobCount}} trabajos del pipeline',
ja: '{{pipelineJobCount}} 件のジョブ',
ko: '파이프라인 작업 {{pipelineJobCount}}개',
zh: '{{pipelineJobCount}} 个作业'
}
],
[
'No checks found.',
{
es: 'No se encontraron comprobaciones.',
ja: 'チェックが見つかりませんでした。',
ko: '검사를 찾을 수 없습니다.',
zh: '未找到检查项。'
}
],
[
'Invalid checks response',
{
es: 'Respuesta de comprobaciones no válida',
ja: 'チェックの応答が無効です',
ko: '잘못된 검사 응답',
zh: '检查响应无效'
}
],
[
'Order Linear Issues',
{
es: 'Ordenar incidencias de Linear',
ja: 'Linear の課題を並べ替える',
ko: 'Linear 이슈 정렬',
zh: '排序 Linear 议题'
}
],
[
'Group Linear Issues',
{
es: 'Agrupar incidencias de Linear',
ja: 'Linear の課題をグループ化',
ko: 'Linear 이슈 그룹화',
zh: '对 Linear 议题分组'
}
],
[
'No states available',
{
es: 'No hay estados disponibles',
ja: '利用可能なステータスがありません',
ko: '사용 가능한 상태가 없습니다',
zh: '没有可用的状态'
}
],
['Body', { es: 'Texto normal', ja: '本文', ko: '본문', zh: '正文' }],
['SPEECH MODEL', { es: 'MODELO DE VOZ', ja: '音声モデル', ko: '음성 모델', zh: '语音模型' }],
['Open a shell', { es: 'Abrir un shell', ja: 'シェルを開く', ko: '셸 열기', zh: '打开 shell' }],
[
'Save terminal artifact',
{
es: 'Guardar artefacto de terminal',
ja: 'ターミナルの成果物を保存',
ko: '터미널 아티팩트 저장',
zh: '保存终端产物'
}
],
[
'Push & Create PR',
{
es: 'Hacer push y crear PR',
ja: 'プッシュして PR を作成',
ko: '푸시 및 PR 생성',
zh: '推送并创建 PR'
}
],
[
'Commit & Push',
{ es: 'Commit y push', ja: 'Commit とプッシュ', ko: 'Commit 및 푸시', zh: 'Commit 并推送' }
],
[
'Nothing to pull',
{
es: 'No hay cambios para pull',
ja: 'プルするものはありません',
ko: '풀할 내용이 없습니다',
zh: '没有可拉取的内容'
}
],
[
'Pull ({{behindCommitCount}})',
{
es: 'Hacer pull ({{behindCommitCount}})',
ja: 'プル ({{behindCommitCount}})',
ko: '풀 ({{behindCommitCount}})',
zh: '拉取 ({{behindCommitCount}})'
}
],
[
'Nothing to push',
{
es: 'No hay commits para push',
ja: 'プッシュするものはありません',
ko: '푸시할 내용이 없습니다',
zh: '没有可推送的内容'
}
],
[
'Push ({{aheadCommitCount}})',
{
es: 'Hacer push ({{aheadCommitCount}})',
ja: 'プッシュ ({{aheadCommitCount}})',
ko: '푸시 ({{aheadCommitCount}})',
zh: '推送 ({{aheadCommitCount}})'
}
],
[
'Pull {{behindCommitCount}}, push {{aheadCommitCount}}.',
{
es: 'Pull: {{behindCommitCount}}; push: {{aheadCommitCount}}.',
ja: '{{behindCommitCount}} 件をプルし、{{aheadCommitCount}} 件をプッシュします。',
ko: '{{behindCommitCount}}개를 풀하고 {{aheadCommitCount}}개를 푸시합니다.',
zh: '拉取 {{behindCommitCount}},推送 {{aheadCommitCount}}。'
}
],
[
'{{failingCheckCount}} failing check',
{
es: '{{failingCheckCount}} comprobación fallida',
ja: '失敗したチェック {{failingCheckCount}} 件',
ko: '실패한 검사 {{failingCheckCount}}개',
zh: '{{failingCheckCount}} 项检查失败'
}
],
[
'Failed to load check details',
{
es: 'No se pudieron cargar los detalles de la comprobación',
ja: 'チェックの詳細を読み込めませんでした',
ko: '검사 세부 정보를 불러오지 못했습니다.',
zh: '无法加载检查详细信息'
}
]
])
@@ -4,6 +4,8 @@ import ts from 'typescript-api'
import { collectSourceBindings } from './mobile-localization-source-bindings.mjs'
const SOURCE_BINDINGS = new WeakMap()
const SOURCE_IDENTIFIERS = new WeakMap()
const RENDERED_FUNCTION_RESULTS = new WeakMap()
function isDirectDisplayExpressionParent(parent, child) {
if (
@@ -72,7 +74,7 @@ export function isRenderedJsxExpression(node) {
return false
}
function assignedVariableName(node) {
function assignedVariableIdentifier(node) {
let current = node
while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) {
current = current.parent
@@ -84,14 +86,14 @@ function assignedVariableName(node) {
parent.initializer === current &&
ts.isIdentifier(parent.name)
) {
return parent.name.text
return parent.name
}
return parent &&
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
parent.right === current &&
ts.isIdentifier(parent.left)
? parent.left.text
? parent.left
: undefined
}
@@ -139,6 +141,24 @@ function bindingsFor(sourceFile) {
return bindings
}
function identifiersFor(sourceFile, name) {
let byName = SOURCE_IDENTIFIERS.get(sourceFile)
if (!byName) {
byName = new Map()
function visit(node) {
if (ts.isIdentifier(node)) {
const matches = byName.get(node.text) ?? []
matches.push(node)
byName.set(node.text, matches)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
SOURCE_IDENTIFIERS.set(sourceFile, byName)
}
return byName.get(name) ?? []
}
function unwrapAliasExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
@@ -150,6 +170,68 @@ function unwrapAliasExpression(node) {
return node
}
function propertyNameText(name) {
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : undefined
}
function propertyValueExpressions(node, propertyName, bindings, seen = new Set()) {
const expression = unwrapAliasExpression(node)
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
if (
!binding ||
seen.has(binding) ||
!ts.isVariableDeclaration(binding) ||
!binding.initializer
) {
return []
}
seen.add(binding)
return propertyValueExpressions(binding.initializer, propertyName, bindings, seen)
}
if (!ts.isObjectLiteralExpression(expression)) {
return []
}
return expression.properties.flatMap((property) => {
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propertyName) {
return [property.initializer]
}
if (ts.isShorthandPropertyAssignment(property) && property.name.text === propertyName) {
return [property.name]
}
return ts.isSpreadAssignment(property)
? propertyValueExpressions(property.expression, propertyName, bindings, new Set(seen))
: []
})
}
function expressionTargetsBinding(node, targets, bindings, seen = new Set()) {
const expression = unwrapAliasExpression(node)
if (ts.isIdentifier(expression)) {
const binding = bindings.resolveBinding(expression)
return Boolean(binding && targets.has(binding))
}
let object
let propertyName
if (ts.isPropertyAccessExpression(expression)) {
object = expression.expression
propertyName = expression.name.text
} else if (
ts.isElementAccessExpression(expression) &&
expression.argumentExpression &&
ts.isStringLiteralLike(expression.argumentExpression)
) {
object = expression.expression
propertyName = expression.argumentExpression.text
}
if (!object || !propertyName) {
return false
}
return propertyValueExpressions(object, propertyName, bindings, seen).some((value) =>
expressionTargetsBinding(value, targets, bindings, seen)
)
}
function renderedFunctionBindings(owner, sourceFile, bindings) {
const targets = new Set()
if (owner.name && ts.isIdentifier(owner.name)) {
@@ -212,6 +294,10 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
if (!owner) {
return false
}
const cached = RENDERED_FUNCTION_RESULTS.get(owner)?.get(isRenderedExpression)
if (cached !== undefined) {
return cached
}
const sourceFile = node.getSourceFile()
const bindings = bindingsFor(sourceFile)
const targetBindings = renderedFunctionBindings(owner, sourceFile, bindings)
@@ -222,8 +308,7 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
}
if (
ts.isCallExpression(current) &&
ts.isIdentifier(current.expression) &&
targetBindings.has(bindings.resolveBinding(current.expression)) &&
expressionTargetsBinding(current.expression, targetBindings, bindings) &&
isRenderedExpression(current)
) {
rendered = true
@@ -232,6 +317,9 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
ts.forEachChild(current, visit)
}
visit(sourceFile)
const results = RENDERED_FUNCTION_RESULTS.get(owner) ?? new Map()
results.set(isRenderedExpression, rendered)
RENDERED_FUNCTION_RESULTS.set(owner, results)
return rendered
}
@@ -239,8 +327,13 @@ export function isAssignedToRenderedVariable(node, isRenderedExpression = isRend
if (ts.isTemplateExpression(node)) {
return false
}
const name = assignedVariableName(node)
if (!name) {
const identifier = assignedVariableIdentifier(node)
if (!identifier) {
return false
}
const bindings = bindingsFor(node.getSourceFile())
const targetBinding = bindings.resolveBinding(identifier)
if (!targetBinding) {
return false
}
let owner = node.parent
@@ -250,17 +343,11 @@ export function isAssignedToRenderedVariable(node, isRenderedExpression = isRend
if (!owner) {
return false
}
let rendered = false
function visit(current) {
if (rendered) {
return
}
if (ts.isIdentifier(current) && current.text === name && isRenderedExpression(current)) {
rendered = true
return
}
ts.forEachChild(current, visit)
}
visit(owner)
return rendered
return identifiersFor(node.getSourceFile(), identifier.text).some(
(current) =>
current.pos >= owner.pos &&
current.end <= owner.end &&
bindings.resolveBinding(current) === targetBinding &&
isRenderedExpression(current)
)
}
@@ -19,6 +19,7 @@ function isLexicalScope(node) {
return (
ts.isSourceFile(node) ||
ts.isFunctionLike(node) ||
ts.isModuleBlock(node) ||
ts.isBlock(node) ||
ts.isCaseBlock(node) ||
ts.isCatchClause(node) ||
@@ -39,8 +40,12 @@ function nearestScope(node, sourceFile, predicate = isLexicalScope) {
return sourceFile
}
function functionScope(node, sourceFile) {
return nearestScope(node, sourceFile, ts.isFunctionLike)
function varScope(node, sourceFile) {
return nearestScope(
node,
sourceFile,
(scope) => ts.isFunctionLike(scope) || ts.isModuleBlock(scope)
)
}
function collectDeclarations(sourceFile) {
@@ -77,7 +82,7 @@ function collectDeclarations(sourceFile) {
const scope =
ts.isVariableDeclarationList(declarationList) &&
(declarationList.flags & ts.NodeFlags.BlockScoped) === 0
? functionScope(node, sourceFile)
? varScope(node, sourceFile)
: nearestScope(node, sourceFile)
for (const [name, declaration] of bindingDeclarations(node.name, node)) {
add(scope, name, declaration)
@@ -5,6 +5,17 @@ import { collectSourceBindings } from './mobile-localization-source-bindings.mjs
const MOBILE_I18N_MODULE_RE = /(?:^|\/)mobile-i18n$/
function unwrapAliasExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
return unwrapAliasExpression(node.expression)
}
return node
}
function resolvesTo(identifier, bindings) {
return bindings.resolveBinding(identifier)
}
@@ -153,79 +164,141 @@ export function collectMobileTranslationBindings(sourceFile) {
function addBinding(identifier, bindingSet, nameSet) {
const binding = bindings.resolveBinding(identifier)
if (!binding) {
return
return false
}
const changed = !bindingSet.has(binding)
bindingSet.add(binding)
nameSet?.add(identifier.text)
return changed
}
function collectDestructuredAliases(node) {
if (!ts.isObjectBindingPattern(node.name) || !node.initializer) {
return
function destructuredTargets(name) {
const expression = unwrapAliasExpression(name)
if (ts.isObjectBindingPattern(expression)) {
return expression.elements.flatMap((element) => {
if (!ts.isIdentifier(element.name) || element.dotDotDotToken) {
return []
}
const propertyName = element.propertyName ?? element.name
return ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName)
? [{ propertyName: propertyName.text, target: element.name }]
: []
})
}
const namespaceSource = isMobileI18nNamespace(node.initializer, bindings)
const instanceSource = isMobileI18nInstance(node.initializer, bindings)
if (!ts.isObjectLiteralExpression(expression)) {
return []
}
return expression.properties.flatMap((property) => {
if (ts.isShorthandPropertyAssignment(property)) {
return [{ propertyName: property.name.text, target: property.name }]
}
if (!ts.isPropertyAssignment(property)) {
return []
}
const target = unwrapAliasExpression(property.initializer)
const propertyName = property.name
return ts.isIdentifier(target) &&
(ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName))
? [{ propertyName: propertyName.text, target }]
: []
})
}
function collectDestructuredAliases(name, initializer) {
const namespaceSource = isMobileI18nNamespace(initializer, bindings)
const instanceSource = isMobileI18nInstance(initializer, bindings)
if (!namespaceSource && !instanceSource) {
return
return false
}
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)
let changed = false
for (const { propertyName, target } of destructuredTargets(name)) {
if (propertyName === 't') {
changed = addBinding(target, translatorBindings, translatorNames) || changed
} else if (namespaceSource && propertyName === 'createMobileTranslator') {
changed = addBinding(target, factoryBindings) || changed
} else if (namespaceSource && propertyName === 'mobileI18n') {
changed = addBinding(target, instanceBindings, instanceNames) || changed
} else if (instanceSource && propertyName === 'getFixedT') {
changed = addBinding(target, fixedFactoryBindings) || changed
}
}
return changed
}
function addPrefixedBinding(identifier, prefix) {
const binding = bindings.resolveBinding(identifier)
if (!binding || prefixedTranslatorBindings.has(binding)) {
return false
}
prefixedTranslatorBindings.set(binding, prefix)
prefixedTranslatorNames.set(identifier.text, prefix)
return true
}
function collectIdentifierAlias(identifier, initializer) {
let changed = false
if (isMobileI18nNamespace(initializer, bindings)) {
changed = addBinding(identifier, namespaceBindings, namespaceNames) || changed
} else if (isTranslatorFactory(initializer, bindings)) {
changed = addBinding(identifier, factoryBindings) || changed
}
if (isFixedTranslatorFactory(initializer, bindings)) {
changed = addBinding(identifier, fixedFactoryBindings) || changed
}
if (isMobileI18nInstance(initializer, bindings)) {
changed = addBinding(identifier, instanceBindings, instanceNames) || changed
}
if (isEnglishFixedTranslator(initializer, bindings)) {
changed = addBinding(identifier, fixedTranslatorBindings, fixedTranslatorNames) || changed
}
const prefix =
mobileTranslatorPrefix(initializer, bindings) ?? directTranslatorPrefix(initializer, bindings)
return prefix === undefined ? changed : addPrefixedBinding(identifier, prefix) || changed
}
function collectLocalTranslators(node) {
if (ts.isVariableDeclaration(node)) {
collectDestructuredAliases(node)
let name
let initializer
if (ts.isVariableDeclaration(node) && node.initializer) {
name = node.name
initializer = node.initializer
} else if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken
) {
name = node.left
initializer = node.right
}
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
const binding = bindings.resolveBinding(node.name)
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)
fixedTranslatorNames.add(node.name.text)
}
const prefix =
mobileTranslatorPrefix(node.initializer, bindings) ??
directTranslatorPrefix(node.initializer, bindings)
if (prefix !== undefined) {
prefixedTranslatorBindings.set(binding, prefix)
prefixedTranslatorNames.set(node.name.text, prefix)
let changed = false
if (name && initializer) {
changed = collectDestructuredAliases(name, initializer) || changed
const target = unwrapAliasExpression(name)
if (ts.isIdentifier(target)) {
changed = collectIdentifierAlias(target, initializer) || changed
}
}
ts.forEachChild(node, collectLocalTranslators)
ts.forEachChild(node, (child) => {
changed = collectLocalTranslators(child) || changed
})
return changed
}
collectLocalTranslators(sourceFile)
function bindingCount() {
return [
factoryBindings,
fixedFactoryBindings,
fixedTranslatorBindings,
instanceBindings,
namespaceBindings,
prefixedTranslatorBindings,
translatorBindings
].reduce((total, collection) => total + collection.size, 0)
}
let previousCount
do {
previousCount = bindingCount()
collectLocalTranslators(sourceFile)
} while (bindingCount() > previousCount)
return bindings
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { collectMobileTranslationCalls } from './verify-mobile-localization-catalog.mjs'
describe('mobile-localization-translation-bindings', () => {
it('finds forward and assignment translator aliases to a fixed point', () => {
const sourceText = `
import { createMobileTranslator, t } from '@/i18n/mobile-i18n'
import * as i18n from '@/i18n/mobile-i18n'
export function forwardLabel() {
const tr = laterAlias
return tr('example.forward')
}
const laterAlias = t
let assigned
assigned = t
let destructured
;({ t: destructured } = i18n)
let prefixed
prefixed = createMobileTranslator('example')
export const labels = [
assigned('example.assigned'),
destructured('example.destructured'),
prefixed('prefixed')
]
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map((call) => call.keys)).toEqual([
['example.forward'],
['example.assigned'],
['example.destructured'],
['example.prefixed']
])
})
it('keeps namespace var translators inside their module scope', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
namespace Local {
var t = (value) => value
export const raw = t('not-a-translation-key')
}
export const translated = t('example.translated')
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map((call) => call.keys)).toEqual([['example.translated']])
})
})
@@ -12,7 +12,9 @@ import {
mobileTranslationCallPrefix
} from './mobile-localization-translation-bindings.mjs'
import {
EXACT_LANGUAGE_NEUTRAL_KEYS,
PRODUCT_GLOSSARY,
REVIEWED_KEY_TRANSLATIONS,
REQUIRED_TARGET_TRANSLATIONS
} from './mobile-localization-product-glossary.mjs'
@@ -294,7 +296,8 @@ function verifyTerminology(englishValue, localeValue, locale, catalogName, key)
if (locale === 'zh' && /\bagent/i.test(englishValue) && localeValue.includes('检测剂')) {
issues.push(`${catalogName} agent terminology mismatch: ${key} uses 检测剂`)
}
const expectedValue = PRODUCT_GLOSSARY.get(englishValue)?.[locale]
const expectedValue =
REVIEWED_KEY_TRANSLATIONS.get(key)?.[locale] ?? PRODUCT_GLOSSARY.get(englishValue)?.[locale]
if (expectedValue && localeValue !== expectedValue) {
issues.push(`${catalogName} product terminology mismatch: ${key} must use ${expectedValue}`)
}
@@ -415,7 +418,10 @@ function verifyLocaleEntries(
if (!sameValues(englishPlaceholders, localePlaceholders)) {
issues.push(`${catalogName} placeholder mismatch: ${key}`)
}
if (EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) && localeValue !== englishValue) {
if (
(EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) || EXACT_LANGUAGE_NEUTRAL_KEYS.has(key)) &&
localeValue !== englishValue
) {
issues.push(`${catalogName} must preserve language-neutral value: ${key}`)
}
issues.push(...verifyProtectedLiterals(englishValue, localeValue, catalogName, key))
@@ -486,6 +486,53 @@ export const labels = [
expect(report).toContain('es.json product terminology mismatch: example.openSource')
})
it('protects terminal legends and reviewed domain terminology', async () => {
const root = makeProject({
sourceText: `
import { t } from '@/i18n/mobile-i18n'
export const labels = [
t('terminalAccessoryKeyCatalog.del'),
t('mobileRichMarkdownEditor.h2'),
t('task.pipelineJob', { pipelineJobCount: 2 }),
t('task.openShell'),
t('voiceSettings.togglePress')
]
`,
catalogs: {
en: {
terminalAccessoryKeyCatalog: { del: 'Del' },
mobileRichMarkdownEditor: { h2: 'H2' },
task: { pipelineJob: '{{pipelineJobCount}} jobs', openShell: 'Open a shell' },
voiceSettings: {
togglePress: 'Toggle: press once to start, again to stop. Hold: dictate while held.'
}
},
es: {
terminalAccessoryKeyCatalog: { del: 'Supr' },
task: { pipelineJob: '{{pipelineJobCount}} empleos', openShell: 'Abrir una concha' }
},
ko: {
voiceSettings: {
togglePress:
'토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 보류: 누르고 있는 동안 지시합니다.'
}
},
zh: { mobileRichMarkdownEditor: { h2: '氢2' } }
}
})
const report = await runFailedVerification(root)
expect(report).toContain(
'es.json must preserve language-neutral value: terminalAccessoryKeyCatalog.del'
)
expect(report).toContain(
'zh.json must preserve language-neutral value: mobileRichMarkdownEditor.h2'
)
expect(report).toContain('es.json product terminology mismatch: task.pipelineJob')
expect(report).toContain('es.json product terminology mismatch: task.openShell')
expect(report).toContain('ko.json product terminology mismatch: voiceSettings.togglePress')
})
it('validates placeholders and extra keys in present translations', async () => {
const en = { m: { greeting: 'Hello {{name}}' } }
const root = makeProject({
+42 -42
View File
@@ -60,7 +60,7 @@
"subIssues": "Subtemas",
"noPipeline": "No hay ejecuciones de pipeline para este MR.",
"none": "Ninguno",
"pipelineJob": "{{pipelineJobCount}} empleos",
"pipelineJob": "{{pipelineJobCount}} trabajos del pipeline",
"fileContents": "Contenido del archivo no disponible.",
"binary": "Archivo binario.",
"markViewed": "Marcar visto",
@@ -68,7 +68,7 @@
"view": "Vista",
"hide": "Ocultar",
"changed": "Archivos modificados",
"noChecks": "No se encontraron cheques.",
"noChecks": "No se encontraron comprobaciones.",
"rerunAll": "Vuelva a ejecutar todo",
"rerunFailed": "Error al volver a ejecutar",
"refresh": "Actualizar",
@@ -188,8 +188,8 @@
"createIssueSelectedRepository": "Cree un problema en el repositorio seleccionado.",
"sortTasks": "Ordenar tareas",
"displayProperties": "Propiedades de visualización",
"orderLinearIssues": "Ordenar cuestiones Lineares",
"groupLinearIssues": "Problemas Lineares del grupo",
"orderLinearIssues": "Ordenar incidencias de Linear",
"groupLinearIssues": "Agrupar incidencias de Linear",
"linearView": "Vista Linear",
"noStates": "No hay estados disponibles",
"loadingStates": "Cargando estados...",
@@ -266,7 +266,7 @@
"display": "Mostrar",
"tasks": "Tareas",
"allWorkspaces": "Todos los espacios de trabajo",
"viewOrder": "Ver pedido",
"viewOrder": "Orden de vista",
"send": "Enviar comentario",
"addComment": "Agregar un comentario",
"failedUpdateIssueSource": "No se pudo actualizar la fuente del problema",
@@ -276,7 +276,7 @@
"failedAddLinear": "No se pudo agregar el comentario Linear",
"failedUpdateLinear": "No se pudo actualizar el problema Linear",
"failedMerge": "No se pudo fusionar",
"gitHubReportsMergeConflictsOpen": "GitHub informa conflictos de fusión. Open in GitHub to continue.",
"gitHubReportsMergeConflictsOpen": "GitHub informa de conflictos de fusión. Abre GitHub para continuar.",
"failedReply": "No se pudo responder",
"failedAddReview": "No se pudo agregar el comentario de revisión",
"unableComment": "No se puede comentar sin el SHA del head del PR.",
@@ -310,7 +310,7 @@
"failedSave": "No se pudo guardar el ajuste preestablecido disperso.",
"failedSearch": "No se pudieron buscar ramas.",
"failedLoadSparse": "No se pudieron cargar ajustes preestablecidos escasos.",
"openShell": "abrir un caparazón",
"openShell": "Abrir un shell",
"failedLoadIssue": "No se pudieron cargar los tipos de problemas",
"failedLoadAssignees": "No se pudieron cargar los asignados",
"failedLoadLabels": "No se pudieron cargar las etiquetas",
@@ -471,7 +471,7 @@
"failedReopen": "No se pudo reabrir el hilo",
"failedResolve": "No se pudo resolver el hilo",
"failedSync": "No se pudo sincronizar el estado visto con GitHub.",
"invalid": "Respuesta de cheques no válidos",
"invalid": "Respuesta de comprobaciones no válida",
"failedUpdateGitHubStatus": "No se pudo actualizar el estado de GitHub",
"failedUpdateGitHubItem": "No se pudo actualizar el elemento de GitHub",
"selectedAgent": "El agent seleccionado está deshabilitado. Elige un agent habilitado antes de crear.",
@@ -762,7 +762,7 @@
"backspace": "Retroceso",
"space": "Espacio",
"shiftTab": "Pestaña Mayús",
"shiftPlus": "Mayús+Tab",
"shiftPlus": "Shift+Tab",
"escape": "Escapar"
},
"terminalWebviewEngineErrorState": {
@@ -787,11 +787,11 @@
"arrowDown": "Flecha hacia abajo",
"arrowUp": "flecha arriba",
"insert": "Insertar",
"pgDn": "AvPág",
"pgUp": "Re Pág",
"shift": "Cambio",
"alt": "Alt.",
"ctrl": "Control"
"pgDn": "PgDn",
"pgUp": "PgUp",
"shift": "Shift",
"alt": "Alt",
"ctrl": "Ctrl"
},
"terminalWebviewReadyWatchdog": {
"terminal": "El terminal no se inicializó: no hay señal de listo desde la vista del terminal"
@@ -961,8 +961,8 @@
"resolveStage": "Resuelva o organice cambios antes de crear una solicitud de extracción.",
"resolveConflicts": "Resuelva los conflictos antes de crear una solicitud de extracción.",
"creating": "Creando reseña...",
"force": "Fuerza empujando con arrendamiento...",
"pushing": "Impulsando commits...",
"force": "Haciendo push forzado con lease...",
"pushing": "Haciendo push de commits...",
"publishing": "Publicando rama...",
"committing": "Confirmando cambios...",
"generating": "Generando mensaje de commit...",
@@ -970,7 +970,7 @@
"failed": "No se han podido realizar cambios en la etapa"
},
"mobileHostedReviewService": {
"pushFailed": "El empujón falló. Resuelva el error de inserción y vuelva a intentarlo.",
"pushFailed": "Falló el push. Resuelve el error de push y vuelve a intentarlo.",
"pushSucceeded": "El push se realizó correctamente, pero falló la creación de {{reviewType}}: {{errorMessage}}",
"failed": "No se pudo crear la solicitud de extracción"
},
@@ -978,10 +978,10 @@
"switch": "Cambiar rama",
"rebase": "Rebase sobre la base",
"publishBranch": "Publicar rama",
"pushCreate": "Impulsar y crear PR",
"pushCreate": "Hacer push y crear PR",
"create": "Crear PR",
"commitSync": "Commit y sincronizar",
"commitPush": "Commit y presionar",
"commitPush": "Commit y push",
"pullRequests": "Las solicitudes de extracción no están disponibles para este repo",
"checking": "Comprobando el estado de la rama...",
"publishBranchFirst": "Publicar rama primero",
@@ -995,10 +995,10 @@
"branchUp": "La rama está actualizada.",
"sync": "Sincronizar",
"syncBehind": "Sincronización (↓{{behindCommitCount}} ↑{{aheadCommitCount}})",
"nothingPull": "Nada que tirar",
"pullBehind": "Tirar ({{behindCommitCount}})",
"nothingPush": "Nada que empujar",
"pushAhead": "Empujar ({{aheadCommitCount}})"
"nothingPull": "No hay cambios para pull",
"pullBehind": "Hacer pull ({{behindCommitCount}})",
"nothingPush": "No hay commits para push",
"pushAhead": "Hacer push ({{aheadCommitCount}})"
},
"useMobileCommitFailureRecovery": {
"failed": "No se pudo iniciar el agent",
@@ -1048,7 +1048,7 @@
},
"mobilePrCreate": {
"branch": "Esta rama aún no está lista para un {{reviewType}}.",
"push": "Empuje la rama base antes de crear un {{reviewType}}.",
"push": "Haz push de la rama base antes de crear un {{reviewType}}.",
"creatingReviewTypeFork": "No se admite la creación de un {{reviewType}} desde esta bifurcación.",
"reviewTypeAlreadyExists": "Ya existe un {{reviewType}} para esta rama.",
"creatingReviewTypeS": "La creación de {{reviewType}} no es compatible con este repo.",
@@ -1065,7 +1065,7 @@
},
"mobileSourceControlPrimaryAction": {
"nothing": "No hay nada para hacer commit. La rama está actualizada.",
"forcePushLease": "Fuerce el envío con arrendamiento para actualizar la rama remota.",
"forcePushLease": "Haz un push forzado con lease para actualizar la rama remota.",
"publishBranchOrigin": "Publicar esta rama en origen.",
"check": "Haz checkout de una rama antes de publicar commits.",
"stageLeast": "Prepare al menos un archivo para commit.",
@@ -1075,16 +1075,16 @@
"resolve": "Resolver conflictos antes de comprometerse.",
"try": "Inténtelo nuevamente una vez que finalice la operación remota.",
"remote": "Operación remota en progreso.",
"forcePushProgress": "Empuje forzado en curso.",
"forcePushProgress": "Push forzado en curso.",
"commitProgress": "Compromiso en progreso.",
"publishBranch": "Publicar rama",
"sync": "Sincronizar",
"stageAll": "Hacer stage de todo",
"pullBehind": "Tire de {{behindCommitCount}}, presione {{aheadCommitCount}}.",
"pullCommitCountCommit": "Tire del commit {{commitCount}}.",
"pullCommitCountCommits": "Extraiga las commits de {{commitCount}}.",
"pushCommitCountCommit": "Empuje el commit {{commitCount}}.",
"pushCommitCountCommits": "Empuje {{commitCount}} commits."
"pullBehind": "Pull: {{behindCommitCount}}; push: {{aheadCommitCount}}.",
"pullCommitCountCommit": "Haz pull de {{commitCount}} commit.",
"pullCommitCountCommits": "Haz pull de {{commitCount}} commits.",
"pushCommitCountCommit": "Haz push de {{commitCount}} commit.",
"pushCommitCountCommits": "Haz push de {{commitCount}} commits."
},
"mobileHostedReviewGitPreparation": {
"commit": "La Commit falló",
@@ -1496,7 +1496,7 @@
"label": "Etiqueta",
"add": "Agregar",
"more": "Más teclas: tabulador, flechas, F1–F12…",
"key": "Clave",
"key": "Tecla",
"modifiers": "Modificadores",
"show": "Mostrar, ocultar o reordenar las teclas de método abreviado",
"manage": "Administrar atajos",
@@ -1509,8 +1509,8 @@
"navigation": "Navegación",
"editing": "Edición",
"eGPnpm": "p. ej. pnpm build",
"eGBuild": "p.ej. Construir",
"pick": "Elige una llave"
"eGBuild": "p. ej., Compilar",
"pick": "Elige una tecla"
},
"dragReorderList": {
"moveDown": "Bajar",
@@ -1670,7 +1670,7 @@
"strike": "Tachado",
"italic": "Cursiva",
"bold": "Negrita",
"body": "Cuerpo"
"body": "Texto normal"
},
"mobileWorkspaceNameInput": {
"workspace": "Nombre del espacio de trabajo"
@@ -1833,8 +1833,8 @@
"mobilePrComposeForm": {
"createReview": "Crear {{reviewType}}",
"createDraftReview": "Crear borrador {{reviewType}}",
"pushCreateReview": "Empujar y crear {{reviewType}}",
"pushCreateDraft": "Enviar y crear borrador {{reviewType}}",
"pushCreateReview": "Hacer push y crear {{reviewType}}",
"pushCreateDraft": "Hacer push y crear borrador de {{reviewType}}",
"createDraft": "Crear como borrador",
"generatingTitle": "Generando título y descripción…",
"reviewTypeDescription": "{{reviewType}} descripción",
@@ -1883,9 +1883,9 @@
"fixFailing": "Solucionar controles fallidos con IA",
"inspect": "Inspecciona los detalles o inicia una pasada de corrección con IA.",
"rerun": "Volver a ejecutar comprobaciones fallidas",
"failingCheckCountFailingCheck": "{{failingCheckCount}} cheque fallido",
"failingCheckCountFailingChecks": "{{failingCheckCount}} controles fallidos",
"failed": "No se pudieron cargar los detalles del cheque"
"failingCheckCountFailingCheck": "{{failingCheckCount}} comprobación fallida",
"failingCheckCountFailingChecks": "{{failingCheckCount}} comprobaciones fallidas",
"failed": "No se pudieron cargar los detalles de la comprobación"
},
"prcommentCard": {
"permanently": "Esto elimina permanentemente el comentario en GitHub.",
@@ -2059,7 +2059,7 @@
},
"mobileFilePreviewScreen": {
"unableLoad": "No se puede cargar la vista previa",
"save": "Guardar artefacto terminal",
"save": "Guardar artefacto de terminal",
"back": "Volver a archivos",
"unsaved": "Las ediciones no guardadas se perderán.",
"discardChanges": "¿Descartar cambios?",
@@ -2386,7 +2386,7 @@
},
"voiceSettings": {
"speechModelMessage": "Modelo de voz",
"speechModel": "MODELO DE DISCURSO",
"speechModel": "MODELO DE VOZ",
"togglePress": "Alternar: presione una vez para comenzar y nuevamente para detener. Mantener: dictar mientras se mantiene presionado.",
"dictationMode": "Modo de dictado",
"dictate": "Dicta texto en cualquier panel enfocado de tu escritorio.",
+39 -39
View File
@@ -61,7 +61,7 @@
"subIssues": "サブ Issue",
"noPipeline": "この MR ではパイプラインは実行されません。",
"none": "なし",
"pipelineJob": "{{pipelineJobCount}} の求人",
"pipelineJob": "{{pipelineJobCount}} 件のジョブ",
"pipeline": "パイプライン",
"fileContents": "ファイルの内容は利用できません。",
"binary": "バイナリファイル。",
@@ -192,10 +192,10 @@
"createIssueSelectedRepository": "選択したリポジトリに Issue を作成します。",
"sortTasks": "タスクの並べ替え",
"displayProperties": "表示プロパティ",
"orderLinearIssues": "順序 Linear Issue",
"groupLinearIssues": "グループの Linear Issue",
"orderLinearIssues": "Linear の課題を並べ替える",
"groupLinearIssues": "Linear の課題をグループ化",
"linearView": "リニアビュー",
"noStates": "利用可能な州がありません",
"noStates": "利用可能なステータスがありません",
"loadingStates": "状態を読み込み中...",
"changeStatus": "ステータスの変更",
"allTeams": "全チーム",
@@ -272,7 +272,7 @@
"display": "画面",
"tasks": "タスク",
"allWorkspaces": "すべてのワークスペース",
"viewOrder": "注文を見る",
"viewOrder": "表示順",
"send": "コメントを送信",
"addComment": "コメントを追加",
"failedUpdateIssueSource": "Issue ソースの更新に失敗しました",
@@ -383,7 +383,7 @@
"sortGithub": "並べ替え: {{githubProjectSortLabel}}",
"fieldsGithub": "フィールド: {{githubProjectFieldsLabel}}",
"groupLinearGroup": "グループ: {{linearGroupLabel}}",
"orderLinearOrder": "注文: {{linearOrderLabel}}",
"orderLinearOrder": "順序: {{linearOrderLabel}}",
"preferred": "{{repoName}} では、アップストリームの優先 Issue ソースが利用できません。原点を使用します。",
"plus": "+{{githubProjectSummaryFieldCount}} フィールド",
"baseBranch": "{{baseBranch}}に",
@@ -483,7 +483,7 @@
"failedReopen": "スレッドを再開できませんでした",
"failedResolve": "スレッドを解決できませんでした",
"failedSync": "表示された状態を GitHub と同期できませんでした。",
"invalid": "無効なチェックの応答",
"invalid": "チェックの応答が無効です",
"failedUpdateGitHubStatus": "GitHub ステータスの更新に失敗しました",
"failedUpdateGitHubItem": "GitHub アイテムの更新に失敗しました",
"selectedAgent": "選択した agent は無効になっています。作成する前に、有効な agent を選択。",
@@ -762,25 +762,25 @@
"clearLine": "カーソル前の行をクリア",
"delete": "単語を後方に削除",
"end": "行末",
"start": "行の始まり",
"reverse": "逆引き検索",
"suspend": "プロセスを一時停止する",
"clearScreen": "画面をクリアする",
"start": "行頭",
"reverse": "逆方向検索",
"suspend": "プロセスを一時停止",
"clearScreen": "画面をクリア",
"send": "EOFを送信",
"interrupt": "割り込み terminal",
"interrupt": "ターミナルに割り込み",
"arrowRight": "右矢印",
"arrowLeft": "左矢印",
"arrowDown": "下矢印",
"arrowUp": "上矢印",
"forward": "前方削除",
"del": "デル",
"del": "Del",
"backspace": "バックスペース",
"space": "スペース",
"shiftTab": "シフトタブ",
"enter": "入力",
"tab": "タブ",
"escape": "逃げる",
"esc": "ESC"
"shiftTab": "Shift+Tab",
"enter": "Enterキー",
"tab": "Tabキー",
"escape": "Escapeキー",
"esc": "Esc"
},
"terminalWebviewEngineErrorState": {
"reload": "リロード",
@@ -797,17 +797,17 @@
"terminalAccessoryKeys": {
"pageDown": "ページダウン",
"pageUp": "ページアップ",
"end": "終わり",
"home": "ホーム",
"end": "Endキー",
"home": "Homeキー",
"arrowRight": "右矢印",
"arrowLeft": "左矢印",
"arrowDown": "下矢印",
"arrowUp": "上矢印",
"insert": "入れる",
"pgDn": "PGD​​N",
"ins": "インス",
"shift": "シフト",
"alt": "代替"
"insert": "Insertキー",
"pgDn": "PgDn",
"ins": "Ins",
"shift": "Shift",
"alt": "Alt"
},
"terminalWebviewReadyWatchdog": {
"terminal": "Terminal が初期化されませんでした - Terminal ビューから準備完了信号がありません"
@@ -995,7 +995,7 @@
"rebase": "ベースにリベースする",
"publishBranch": "ブランチを公開",
"fetch": "フェッチ",
"pushCreate": "PR のプッシュと作成",
"pushCreate": "プッシュして PR を作成",
"create": "PR を作成",
"commitSync": "Commit と同期",
"commitPush": "Commit とプッシュ",
@@ -1012,10 +1012,10 @@
"branchUp": "ブランチは最新です",
"sync": "同期",
"syncBehind": "同期 (↓{{behindCommitCount}} ↑{{aheadCommitCount}})",
"nothingPull": "何も引っ張るものがない",
"nothingPull": "プルするものはありません",
"pull": "プル",
"pullBehind": "プル ({{behindCommitCount}})",
"nothingPush": "押し付けるものは何もない",
"nothingPush": "プッシュするものはありません",
"push": "プッシュ",
"pushAhead": "プッシュ ({{aheadCommitCount}})"
},
@@ -1102,7 +1102,7 @@
"sync": "同期",
"stageAll": "ステージオール",
"forcePush": "フォースプッシュ",
"pullBehind": "{{behindCommitCount}} を引いて、{{aheadCommitCount}} を押します。",
"pullBehind": "{{behindCommitCount}} 件をプルし、{{aheadCommitCount}} 件をプッシュします。",
"pullCommitCountCommit": "{{commitCount}} commit をプルします。",
"pullCommitCountCommits": "{{commitCount}} commits をプルします。",
"pushCommitCountCommit": "{{commitCount}} commit をプッシュします。",
@@ -1528,7 +1528,7 @@
"label": "ラベル",
"add": "追加",
"more": "その他のキー — Tab、矢印、F1 ~ F12…",
"key": "鍵",
"key": "キー",
"modifiers": "修飾子",
"show": "ショートカット キーの表示、非表示、または並べ替え",
"manage": "ショートカットの管理",
@@ -1541,7 +1541,7 @@
"navigation": "ナビゲーション",
"editing": "編集",
"eGPnpm": "例: pnpm build",
"eGBuild": "例えば建てる",
"eGBuild": "例: ビルド",
"pick": "キーを選択してください"
},
"dragReorderList": {
@@ -1702,7 +1702,7 @@
"strike": "取り消し線",
"italic": "イタリック",
"bold": "太字",
"body": "体"
"body": "本文"
},
"mobileWorkspaceNameInput": {
"workspace": "ワークスペース名"
@@ -1866,8 +1866,8 @@
"mobilePrComposeForm": {
"createReview": "{{reviewType}} を作成する",
"createDraftReview": "ドラフト {{reviewType}} を作成する",
"pushCreateReview": "プッシュして{{reviewType}}を作成",
"pushCreateDraft": "プッシュしてドラフトを作成 {{reviewType}}",
"pushCreateReview": "プッシュして {{reviewType}} を作成",
"pushCreateDraft": "プッシュしてドラフト {{reviewType}} を作成",
"createDraft": "下書きとして作成",
"base": "ベース",
"generatingTitle": "タイトルと説明を生成しています…",
@@ -1919,9 +1919,9 @@
"inspect": "詳細を検査するか、AI 修正パスを開始します。",
"rerun": "失敗したチェックを再実行する",
"checks": "チェック",
"failingCheckCountFailingCheck": "{{failingCheckCount}} チェックに失敗しました",
"failingCheckCountFailingCheck": "失敗したチェック {{failingCheckCount}} 件",
"failingCheckCountFailingChecks": "{{failingCheckCount}} チェックに失敗しました",
"failed": "チェックの詳細をロードできませんでした"
"failed": "チェックの詳細を読み込めませんでした"
},
"prcommentCard": {
"permanently": "これにより、GitHub 上のコメントが完全に削除されます。",
@@ -2096,7 +2096,7 @@
},
"mobileFilePreviewScreen": {
"unableLoad": "プレビューを読み込めません",
"save": "terminal アーティファクトを保存する",
"save": "ターミナルの成果物を保存",
"back": "ファイルに戻る",
"unsaved": "保存されていない編集内容は失われます。",
"discardChanges": "変更を破棄しますか?",
@@ -2309,8 +2309,8 @@
},
"mobileOnboarding": {
"onboarding": "オンボーディングの進捗状況",
"notification": "Notification settings could not be updated.再試行。",
"your": "Your choice could not be saved.再試行。"
"notification": "通知設定を更新できませんでした。もう一度お試しください。",
"your": "選択内容を保存できませんでした。もう一度お試しください。"
},
"nativeChatSettings": {
"open": "チャット UI でセッションを開く",
+39 -39
View File
@@ -9,7 +9,7 @@
"pr": "PR 현황",
"status": "상태",
"no": "그룹화 없음",
"server": "서버 주문",
"server": "서버 순서",
"manual": "수동",
"repository": "저장소, 워크스페이스 이름",
"most": "가장 최근 출력 먼저",
@@ -61,7 +61,7 @@
"subIssues": "하위 이슈",
"noPipeline": "이 MR에 실행된 파이프라인이 없습니다.",
"none": "없음",
"pipelineJob": "{{pipelineJobCount}} 직업",
"pipelineJob": "파이프라인 작업 {{pipelineJobCount}}개",
"pipeline": "파이프라인",
"fileContents": "파일 콘텐츠를 사용할 수 없습니다.",
"binary": "바이너리 파일.",
@@ -70,7 +70,7 @@
"view": "보기",
"hide": "숨기기",
"changed": "변경된 파일",
"noChecks": "수표를 찾을 수 없습니다.",
"noChecks": "검사를 찾을 수 없습니다.",
"rerunAll": "모두 다시 실행",
"rerunFailed": "재실행 실패",
"refresh": "새로고침",
@@ -192,10 +192,10 @@
"createIssueSelectedRepository": "선택한 저장소에서 이슈를 생성합니다.",
"sortTasks": "작업 정렬",
"displayProperties": "디스플레이 속성",
"orderLinearIssues": "주문 Linear 문제",
"groupLinearIssues": "그룹 Linear 문제",
"orderLinearIssues": "Linear 이슈 정렬",
"groupLinearIssues": "Linear 이슈 그룹화",
"linearView": "Linear 보기",
"noStates": "사용 가능한 주 없음",
"noStates": "사용 가능한 상태가 없습니다",
"loadingStates": "상태 로드 중...",
"changeStatus": "상태 변경",
"allTeams": "모든 팀",
@@ -272,7 +272,7 @@
"display": "표시",
"tasks": "작업",
"allWorkspaces": "모든 워크스페이스",
"viewOrder": "주문 보기",
"viewOrder": "보기 순서",
"send": "댓글 보내기",
"addComment": "댓글 추가",
"failedUpdateIssueSource": "이슈 소스를 업데이트하지 못했습니다.",
@@ -383,7 +383,7 @@
"sortGithub": "정렬: {{githubProjectSortLabel}}",
"fieldsGithub": "필드: {{githubProjectFieldsLabel}}",
"groupLinearGroup": "그룹: {{linearGroupLabel}}",
"orderLinearOrder": "주문: {{linearOrderLabel}}",
"orderLinearOrder": "순서: {{linearOrderLabel}}",
"preferred": "{{repoName}}에는 기본 이슈 소스 업스트림을 사용할 수 없습니다. 원산지를 사용합니다.",
"plus": "+{{githubProjectSummaryFieldCount}} 필드",
"baseBranch": "{{baseBranch}}에",
@@ -483,7 +483,7 @@
"failedReopen": "스레드를 다시 열지 못했습니다.",
"failedResolve": "스레드를 해결하지 못했습니다.",
"failedSync": "본 상태를 GitHub와 동기화하지 못했습니다.",
"invalid": "잘못된 수표 응답",
"invalid": "잘못된 검사 응답",
"failedUpdateGitHubStatus": "GitHub 상태를 업데이트하지 못했습니다.",
"failedUpdateGitHubItem": "GitHub 항목을 업데이트하지 못했습니다.",
"selectedAgent": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.",
@@ -767,18 +767,18 @@
"suspend": "프로세스 일시 중지",
"clearScreen": "화면 지우기",
"send": "EOF 보내기",
"interrupt": "인터럽트 terminal",
"interrupt": "터미널 중단",
"arrowRight": "오른쪽 화살표",
"arrowLeft": "왼쪽 화살표",
"arrowDown": "아래쪽 화살표",
"arrowUp": "위쪽 화살표",
"forward": "앞으로 삭제",
"del": "델",
"backspace": "역행 키이",
"space": "공간",
"shiftTab": "시프트 탭",
"enter": "입력",
"escape": "탈출하다"
"del": "Del",
"backspace": "Backspace 키",
"space": "스페이스",
"shiftTab": "Shift+Tab",
"enter": "Enter 키",
"escape": "Esc 키"
},
"terminalWebviewEngineErrorState": {
"reload": "새로고침",
@@ -795,16 +795,16 @@
"terminalAccessoryKeys": {
"pageDown": "페이지 아래로",
"pageUp": "페이지 위로",
"end": "끝",
"home": "홈",
"end": "End 키",
"home": "Home 키",
"arrowRight": "오른쪽 화살표",
"arrowLeft": "왼쪽 화살표",
"arrowDown": "아래쪽 화살표",
"arrowUp": "위쪽 화살표",
"insert": "삽입",
"ins": "인",
"shift": "옮기다",
"ctrl": "Ctrl 키"
"insert": "Insert 키",
"ins": "Ins",
"shift": "Shift",
"ctrl": "Ctrl"
},
"terminalWebviewReadyWatchdog": {
"terminal": "Terminal이 초기화되지 않았습니다. Terminal 보기에 준비 신호가 없습니다."
@@ -972,7 +972,7 @@
"resolveStage": "PR을 생성하기 전에 변경 사항을 해결하거나 준비하세요.",
"resolveConflicts": "PR을 생성하기 전에 충돌을 해결하세요.",
"creating": "리뷰 작성 중...",
"force": "임대로 강제 추진 중...",
"force": "리스를 사용해 강제 푸시 중...",
"pushing": "commits 푸시 중...",
"publishing": "브랜치 게시 중...",
"committing": "변경사항을 커밋하는 중...",
@@ -1007,12 +1007,12 @@
"branchUp": "브랜치이 최신 상태입니다.",
"sync": "동기화",
"syncBehind": "동기화(↓{{behindCommitCount}} ↑{{aheadCommitCount}})",
"nothingPull": "당길 게 하나도 없어",
"nothingPull": "풀할 내용이 없습니다",
"pull": "풀",
"pullBehind": "당기기({{behindCommitCount}})",
"nothingPush": "밀어붙일 게 하나도 없어",
"pullBehind": "풀 ({{behindCommitCount}})",
"nothingPush": "푸시할 내용이 없습니다",
"push": "푸시",
"pushAhead": "푸시({{aheadCommitCount}})"
"pushAhead": "푸시 ({{aheadCommitCount}})"
},
"useMobileCommitFailureRecovery": {
"failed": "agent를 시작하지 못했습니다.",
@@ -1081,7 +1081,7 @@
"nothing": "commit 할 항목이 없습니다. 브랜치가 최신 상태입니다.",
"push": "푸시",
"pull": "풀",
"forcePushLease": "원격 브랜치를 업데이트하려면 임대를 통해 강제로 푸시하세요.",
"forcePushLease": "원격 브랜치를 업데이트하려면 리스를 사용해 강제 푸시하세요.",
"publishBranchOrigin": "이 브랜치를 원본에 게시합니다.",
"check": "commits을 게시하기 전에 브랜치를 체크아웃하세요.",
"stageLeast": "commit 할 파일을 하나 이상 준비하세요.",
@@ -1097,9 +1097,9 @@
"sync": "동기화",
"stageAll": "모두 스테이지",
"forcePush": "강제 푸시",
"pullBehind": "{{behindCommitCount}}을 당기고, {{aheadCommitCount}}을 누르세요.",
"pullCommitCountCommit": "{{commitCount}} commit을 가져옵니다.",
"pullCommitCountCommits": "{{commitCount}} commits을 가져옵니다.",
"pullBehind": "{{behindCommitCount}}개를 풀하고 {{aheadCommitCount}}개를 푸시합니다.",
"pullCommitCountCommit": "{{commitCount}}개 commit을 풀합니다.",
"pullCommitCountCommits": "{{commitCount}}개 commits을 풀합니다.",
"pushCommitCountCommit": "{{commitCount}} commit을 푸시합니다.",
"pushCommitCountCommits": "{{commitCount}} commits을 푸시합니다."
},
@@ -1698,7 +1698,7 @@
"strike": "취소선",
"italic": "기울임",
"bold": "굵게",
"body": "몸"
"body": "본문"
},
"mobileWorkspaceNameInput": {
"workspace": "워크스페이스 이름"
@@ -1862,8 +1862,8 @@
"mobilePrComposeForm": {
"createReview": "{{reviewType}} 만들기",
"createDraftReview": "초안 {{reviewType}} 만들기",
"pushCreateReview": "{{reviewType}} 푸시 및 생성",
"pushCreateDraft": "초안 푸시 및 생성 {{reviewType}}",
"pushCreateReview": "푸시하고 {{reviewType}} 생성",
"pushCreateDraft": "푸시하고 초안 {{reviewType}} 생성",
"createDraft": "초안으로 만들기",
"base": "베이스",
"generatingTitle": "제목 및 설명 생성 중…",
@@ -1915,9 +1915,9 @@
"inspect": "세부 정보를 검사하거나 AI 수정 패스를 시작하세요.",
"rerun": "실패한 검사 다시 실행",
"checks": "체크",
"failingCheckCountFailingCheck": "{{failingCheckCount}} 확인 실패",
"failingCheckCountFailingCheck": "실패한 검사 {{failingCheckCount}}개",
"failingCheckCountFailingChecks": "{{failingCheckCount}} 검사 실패",
"failed": "수표 세부정보를 로드하지 못했습니다."
"failed": "검사 세부 정보를 불러오지 못했습니다."
},
"prcommentCard": {
"permanently": "GitHub의 댓글이 영구적으로 삭제됩니다.",
@@ -2094,7 +2094,7 @@
},
"mobileFilePreviewScreen": {
"unableLoad": "미리보기를 로드할 수 없습니다.",
"save": "terminal 아티팩트 저장",
"save": "터미널 아티팩트 저장",
"back": "파일로 돌아가기",
"unsaved": "저장되지 않은 수정사항은 손실됩니다.",
"discardChanges": "변경사항을 삭제하시겠습니까?",
@@ -2424,8 +2424,8 @@
},
"voiceSettings": {
"speechModelMessage": "음성 모델",
"speechModel": "연설 모델",
"togglePress": "토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 보류: 누르고 있는 동안 지시합니다.",
"speechModel": "음성 모델",
"togglePress": "토글: 한 번 누르면 시작되고 다시 누르면 중지됩니다. 길게 누르기: 누르고 있는 동안 받아씁니다.",
"dictationMode": "받아쓰기 모드",
"dictate": "데스크탑의 초점이 맞춰진 창에 ​​텍스트를 받아쓰세요.",
"enable": "음성 받아쓰기 활성화",
+36 -36
View File
@@ -61,7 +61,7 @@
"subIssues": "子问题",
"noPipeline": "此 MR 没有运行任何管道。",
"none": "没有任何",
"pipelineJob": "{{pipelineJobCount}} 职位",
"pipelineJob": "{{pipelineJobCount}} 个作业",
"pipeline": "管道",
"fileContents": "文件内容不可用。",
"binary": "二进制文件。",
@@ -70,7 +70,7 @@
"view": "查看",
"hide": "隐藏",
"changed": "已更改文件",
"noChecks": "没有找到检查。",
"noChecks": "未找到检查项。",
"rerunAll": "重新运行全部",
"rerunFailed": "重新运行失败",
"refresh": "刷新",
@@ -194,8 +194,8 @@
"createIssueSelectedRepository": "在选定的存储库中创建议题。",
"sortTasks": "排序任务",
"displayProperties": "显示属性",
"orderLinearIssues": "阶 Linear 议题",
"groupLinearIssues": "群 Linear 议题",
"orderLinearIssues": "排序 Linear 议题",
"groupLinearIssues": "对 Linear 议题分组",
"linearView": "Linear 视图",
"noStates": "没有可用的状态",
"loadingStates": "加载状态...",
@@ -272,7 +272,7 @@
"display": "展示",
"tasks": "任务",
"allWorkspaces": "所有工作区",
"viewOrder": "查看订单",
"viewOrder": "视图顺序",
"send": "发送评论",
"addComment": "添加评论",
"failedUpdateIssueSource": "无法更新议题来源",
@@ -316,7 +316,7 @@
"failedSave": "无法保存稀疏预设。",
"failedSearch": "搜索分支失败。",
"failedLoadSparse": "无法加载稀疏预设。",
"openShell": "打开一个外壳",
"openShell": "打开 shell",
"failedLoadIssue": "无法加载议题类型",
"failedLoadAssignees": "无法加载负责人",
"failedLoadLabels": "加载标签失败",
@@ -383,7 +383,7 @@
"sortGithub": "排序:{{githubProjectSortLabel}}",
"fieldsGithub": "字段:{{githubProjectFieldsLabel}}",
"groupLinearGroup": "组:{{linearGroupLabel}}",
"orderLinearOrder": "订单:{{linearOrderLabel}}",
"orderLinearOrder": "顺序:{{linearOrderLabel}}",
"preferred": "{{repoName}} 的首选议题源上游不可用。使用原点。",
"plus": "+{{githubProjectSummaryFieldCount}} 字段",
"baseBranch": "进入 {{baseBranch}}",
@@ -482,7 +482,7 @@
"failedReopen": "无法重新打开线程",
"failedResolve": "无法解析线程",
"failedSync": "无法将查看的状态与 GitHub 同步。",
"invalid": "无效检查响应",
"invalid": "检查响应无效",
"failedUpdateGitHubStatus": "无法更新 GitHub 状态",
"failedUpdateGitHubItem": "无法更新 GitHub 项目",
"selectedAgent": "所选 agent 已禁用。创建之前选择启用的 agent。",
@@ -765,22 +765,22 @@
"start": "行首",
"reverse": "反向搜索",
"suspend": "暂停进程",
"clearScreen": "清晰的屏幕",
"clearScreen": "清除屏幕",
"send": "发送文件尾",
"interrupt": "中断 terminal",
"interrupt": "中断终端",
"arrowRight": "向右箭头",
"arrowLeft": "向左箭头",
"arrowDown": "向下箭头",
"arrowUp": "向上箭头",
"forward": "转发删除",
"del": "德尔",
"forward": "向前删除",
"del": "Del",
"backspace": "退格键",
"space": "空间",
"shiftTab": "切换选项卡",
"enter": "进入",
"tab": "选项卡",
"escape": "逃脱",
"esc": "Esc键"
"space": "空格键",
"shiftTab": "Shift+Tab 键",
"enter": "Enter 键",
"tab": "Tab 键",
"escape": "Esc 键",
"esc": "Esc"
},
"terminalWebviewEngineErrorState": {
"reload": "重新加载",
@@ -797,19 +797,19 @@
"terminalAccessoryKeys": {
"pageDown": "向下翻页",
"pageUp": "向上翻页",
"end": "结尾",
"home": "家",
"end": "End 键",
"home": "Home 键",
"arrowRight": "向右箭头",
"arrowLeft": "向左箭头",
"arrowDown": "向下箭头",
"arrowUp": "向上箭头",
"insert": "插入",
"pgDn": "前列腺素",
"pgUp": "上页",
"ins": "INS",
"shift": "转移",
"alt": "替代",
"ctrl": "控制键"
"insert": "Insert 键",
"pgDn": "PgDn",
"pgUp": "PgUp",
"ins": "Ins",
"shift": "Shift",
"alt": "Alt",
"ctrl": "Ctrl"
},
"terminalWebviewReadyWatchdog": {
"terminal": "Terminal 未初始化 - Terminal 视图中没有就绪信号"
@@ -977,8 +977,8 @@
"resolveStage": "在创建拉取请求之前解决或暂存更改。",
"resolveConflicts": "在创建拉取请求之前解决冲突。",
"creating": "正在创建评论...",
"force": "用租约强制推动...",
"pushing": "推动 commits...",
"force": "正在使用租约强制推送...",
"pushing": "正在推送 commits...",
"publishing": "正在发布分支...",
"committing": "正在提交更改...",
"generating": "正在生成 commit 消息...",
@@ -1528,7 +1528,7 @@
"label": "标签",
"add": "添加",
"more": "更多按键 — Tab、箭头、F1–F12...",
"key": "标识",
"key": "按键",
"modifiers": "修饰符",
"show": "显示、隐藏或重新排序快捷键",
"manage": "管理快捷键",
@@ -1541,8 +1541,8 @@
"navigation": "导航",
"editing": "编辑",
"eGPnpm": "例如:pnpm build",
"eGBuild": "例如建造",
"pick": "选择一把钥匙"
"eGBuild": "例如:构建",
"pick": "选择按键"
},
"dragReorderList": {
"moveDown": "下移",
@@ -1703,8 +1703,8 @@
"strike": "删除线",
"italic": "斜体",
"bold": "粗体",
"h2": "氢2",
"body": "身体"
"h2": "H2",
"body": "正文"
},
"mobileWorkspaceNameInput": {
"workspace": "工作区名称"
@@ -1921,7 +1921,7 @@
"inspect": "检查详细信息或启动 AI 修复过程。",
"rerun": "重新运行失败的检查",
"checks": "检查项",
"failingCheckCountFailingCheck": "{{failingCheckCount}} 检查失败",
"failingCheckCountFailingCheck": "{{failingCheckCount}} 项检查失败",
"failingCheckCountFailingChecks": "{{failingCheckCount}} 检查失败",
"failed": "无法加载检查详细信息"
},
@@ -2100,7 +2100,7 @@
},
"mobileFilePreviewScreen": {
"unableLoad": "无法加载预览",
"save": "保存 terminal 神器",
"save": "保存终端产物",
"back": "返回文件",
"unsaved": "未保存的编辑将会丢失。",
"discardChanges": "放弃更改?",
@@ -1,35 +1,37 @@
import { t } from '@/i18n/mobile-i18n'
import { mobileI18n, t } from '@/i18n/mobile-i18n'
import type { TerminalAccessoryKey } from './terminal-accessory-keys'
const canonicalTerminalLabel = mobileI18n.getFixedT('en')
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{
id: 'escape',
label: t('terminalAccessoryKeyCatalog.esc'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.esc'),
bytes: '\x1b',
accessibilityLabel: t('terminalAccessoryKeyCatalog.escape')
},
{
id: 'tab',
label: t('terminalAccessoryKeyCatalog.tab'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.tab'),
bytes: '\t',
accessibilityLabel: t('terminalAccessoryKeyCatalog.tab')
},
{
id: 'enter',
label: t('terminalAccessoryKeyCatalog.enter'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.enter'),
bytes: '\r',
accessibilityLabel: t('terminalAccessoryKeyCatalog.enter')
},
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{
id: 'shiftTab',
label: t('terminalAccessoryKeyCatalog.shiftPlus'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.shiftPlus'),
bytes: '\x1b[Z',
accessibilityLabel: t('terminalAccessoryKeyCatalog.shiftTab')
},
{
id: 'space',
label: t('terminalAccessoryKeyCatalog.space'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.space'),
bytes: ' ',
accessibilityLabel: t('terminalAccessoryKeyCatalog.space')
},
@@ -42,7 +44,7 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
},
{
id: 'delete',
label: t('terminalAccessoryKeyCatalog.del'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.del'),
bytes: '\x1b[3~',
accessibilityLabel: t('terminalAccessoryKeyCatalog.forward'),
repeatable: true
@@ -77,55 +79,55 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
},
{
id: 'ctrlC',
label: t('terminalAccessoryKeyCatalog.ctrlPlusC'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusC'),
bytes: '\x03',
accessibilityLabel: t('terminalAccessoryKeyCatalog.interrupt')
},
{
id: 'ctrlD',
label: t('terminalAccessoryKeyCatalog.ctrlPlusD'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusD'),
bytes: '\x04',
accessibilityLabel: t('terminalAccessoryKeyCatalog.send')
},
{
id: 'ctrlL',
label: t('terminalAccessoryKeyCatalog.ctrlPlusL'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusL'),
bytes: '\x0c',
accessibilityLabel: t('terminalAccessoryKeyCatalog.clearScreen')
},
{
id: 'ctrlZ',
label: t('terminalAccessoryKeyCatalog.ctrlPlusZ'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusZ'),
bytes: '\x1a',
accessibilityLabel: t('terminalAccessoryKeyCatalog.suspend')
},
{
id: 'ctrlR',
label: t('terminalAccessoryKeyCatalog.ctrlPlusR'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusR'),
bytes: '\x12',
accessibilityLabel: t('terminalAccessoryKeyCatalog.reverse')
},
{
id: 'ctrlA',
label: t('terminalAccessoryKeyCatalog.ctrlPlus'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlus'),
bytes: '\x01',
accessibilityLabel: t('terminalAccessoryKeyCatalog.start')
},
{
id: 'ctrlE',
label: t('terminalAccessoryKeyCatalog.ctrlPlusE'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusE'),
bytes: '\x05',
accessibilityLabel: t('terminalAccessoryKeyCatalog.end')
},
{
id: 'ctrlW',
label: t('terminalAccessoryKeyCatalog.ctrlPlusW'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusW'),
bytes: '\x17',
accessibilityLabel: t('terminalAccessoryKeyCatalog.delete')
},
{
id: 'ctrlU',
label: t('terminalAccessoryKeyCatalog.ctrlPlusU'),
label: canonicalTerminalLabel('terminalAccessoryKeyCatalog.ctrlPlusU'),
bytes: '\x15',
accessibilityLabel: t('terminalAccessoryKeyCatalog.clearLine')
}