fix(mobile): harden localization coverage

This commit is contained in:
Brennan Benson
2026-08-01 02:59:33 -07:00
parent 49110fb051
commit dcdc6c2e8a
21 changed files with 747 additions and 62 deletions
+4
View File
@@ -1,5 +1,9 @@
# Product source remains owned by its normal reviewers; localization inputs need focused review.
/src/renderer/src/i18n/locales/ @brennanb2025
/mobile/src/i18n/locales/ @brennanb2025
/mobile/locales/ @brennanb2025
/config/scripts/*localization*.mjs @brennanb2025
/config/scripts/*locale*.mjs @brennanb2025
/config/scripts/localize-mobile-strings.mjs @brennanb2025
/config/scripts/mobile-embedded-document-candidates.mjs @brennanb2025
/config/i18next.config.ts @brennanb2025
+5 -4
View File
@@ -53,15 +53,16 @@ The gate scans the desktop renderer plus both mobile source roots. Mobile uses
`config/mobile-localization-coverage-allowlist.json`, which is also committed
empty.
Verify mobile catalog references, conditional keys, interpolation options, and
the translations present in each sparse locale:
Verify mobile catalog references, conditional keys, interpolation options,
protected technical literals, and non-empty translations. The same gate checks
Expo native locale mappings, required permission keys, and English fallbacks:
```sh
pnpm run verify:mobile-localization-catalog
```
The checked-in mobile runtime JSON is a temporary bridge to the planned
canonical PO source and read-only compiler. Feature work updates English only;
The checked-in mobile runtime JSON is a temporary bridge to the canonical PO
source and read-only compiler. Feature work updates English only;
missing target values intentionally use runtime English fallback. Whole-catalog
translation commands are not part of the maintained workflow.
@@ -6,6 +6,7 @@ import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { collectMobileEmbeddedDocumentCandidates } from './mobile-embedded-document-candidates.mjs'
import { classifyMobileStringNode } from './mobile-localization-candidate-rules.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
@@ -444,6 +445,23 @@ export function collectLocalizationCandidates(filePath, sourceText, root = proce
}
visit(sourceFile)
if (mobileSource) {
for (const candidate of collectMobileEmbeddedDocumentCandidates(relativePath, sourceText)) {
const value = compactText(candidate.text)
if (!hasHumanLanguageText(value)) {
continue
}
const position = sourceFile.getLineAndCharacterOfPosition(candidate.start)
reports.push({
area: areaForFile(relativePath),
filePath: relativePath,
...candidate,
line: position.line + 1,
column: position.character + 1,
text: value
})
}
}
return reports
}
@@ -0,0 +1,100 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const EMBEDDED_DOCUMENT_FILE_RE = /(?:-html|webview-html)\.[cm]?[jt]sx?$/
const USER_VISIBLE_ATTRIBUTE_RE =
/\b(aria-description|aria-label|alt|data-placeholder|placeholder|title)\s*=\s*(["'])(.*?)\2/gi
const USER_VISIBLE_PROMPT_RE = /\b(?:window\.)?(alert|confirm|prompt)\(\s*(["'])(.*?)\2/g
const INSERTED_HTML_RE =
/\bexecCommand\(\s*(["'])insertHTML\1\s*,\s*false\s*,\s*(["'])([\s\S]*?)\2\s*\)/g
function maskNonMarkupContent(documentText) {
return documentText.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, (value) =>
value.replace(/[^\n]/g, ' ')
)
}
function capturedOffset(match, capturedValue) {
return (match.index ?? 0) + match[0].indexOf(capturedValue)
}
function collectMarkupCandidates(markup, baseOffset, candidates) {
for (const match of markup.matchAll(USER_VISIBLE_ATTRIBUTE_RE)) {
const value = match[3]
if (!value.includes('${')) {
const start = baseOffset + capturedOffset(match, value)
candidates.push({
start,
end: start + value.length,
kind: `embedded-html-attribute:${match[1].toLowerCase()}`,
text: value,
dynamic: false
})
}
}
for (const match of markup.matchAll(/>([^<]+)</g)) {
const value = match[1]
if (!value.includes('${')) {
const start = baseOffset + capturedOffset(match, value)
candidates.push({
start,
end: start + value.length,
kind: 'embedded-html-text',
text: value,
dynamic: false
})
}
}
}
export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) {
if (!EMBEDDED_DOCUMENT_FILE_RE.test(filePath)) {
return []
}
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceKind
)
const candidates = []
function visit(node) {
if (
(ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) &&
/<!doctype html/i.test(node.getText(sourceFile))
) {
const documentText = node.getText(sourceFile).slice(1, -1)
const baseOffset = node.getStart(sourceFile) + 1
collectMarkupCandidates(maskNonMarkupContent(documentText), baseOffset, candidates)
for (const match of documentText.matchAll(USER_VISIBLE_PROMPT_RE)) {
const value = match[3]
const start = baseOffset + capturedOffset(match, value)
candidates.push({
start,
end: start + value.length,
kind: `embedded-web-${match[1].toLowerCase()}`,
text: value,
dynamic: false
})
}
for (const match of documentText.matchAll(INSERTED_HTML_RE)) {
const markup = match[3]
const markupOffset = baseOffset + capturedOffset(match, markup)
collectMarkupCandidates(markup, markupOffset, candidates)
}
return
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return candidates
}
@@ -13,4 +13,30 @@ function repositoryCount(count) {
expect(candidates.map((candidate) => candidate.text)).toEqual(['repository', 'repositories'])
})
it('finds copy embedded in mobile WebView documents', () => {
const source = String.raw`
export const HTML = \`<!doctype html>
<main data-placeholder="Start writing..."></main>
<button>Copy</button><button>Select All</button>
<script>
window.prompt('Link URL');
document.execCommand('insertHTML', false, '<p>Task</p>');
</script>
\`
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/example-html.ts',
source,
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual([
'Start writing...',
'Copy',
'Select All',
'Link URL',
'Task'
])
})
})
@@ -10,6 +10,44 @@ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
const SOURCE_RELATIVE_ROOTS = [path.join('mobile', 'app'), path.join('mobile', 'src')]
const LOCALES_RELATIVE_DIR = path.join('mobile', 'src', 'i18n', 'locales')
const MOBILE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh']
const NATIVE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh-Hans']
const NATIVE_LOCALES_RELATIVE_DIR = path.join('mobile', 'locales')
const NATIVE_REQUIRED_KEYS = [
'ios.CFBundleDisplayName',
'ios.NSCameraUsageDescription',
'ios.NSLocalNetworkUsageDescription',
'ios.NSMicrophoneUsageDescription',
'ios.NSPhotoLibraryUsageDescription',
'android.app_name'
]
const NATIVE_LOCALE_PATHS = new Map(
NATIVE_LOCALES.map((locale) => [locale, `./locales/${locale}.json`])
)
const RUNTIME_TO_NATIVE_LOCALE = new Map([
['en', 'en'],
['es', 'es'],
['ja', 'ja'],
['ko', 'ko'],
['zh', 'zh-Hans']
])
const PROTECTED_LITERALS = [
'sudo ufw allow 6768',
'npm run dev',
'pnpm build',
'stablyai/orca',
'onOrca.dev',
'orca.yaml',
'Tailscale',
'GitHub',
'GitLab',
'Codex',
'Claude',
'Orca',
'WSL',
'SSH',
'HEAD',
'MR !'
]
const PLACEHOLDER_RE = /\{\{\s*([^,}\s]+)(?:,[^}]*)?\}\}/g
const ENCODED_HTML_ENTITY_RE = /&(?:amp|apos|gt|lt|quot);/i
@@ -45,6 +83,9 @@ async function collectSourceFiles(directory) {
function flattenCatalog(value, catalogName, prefix = '', entries = new Map(), issues = []) {
if (typeof value === 'string') {
entries.set(prefix, value)
if (value.trim().length === 0) {
issues.push(`${catalogName}: ${prefix} must not be empty`)
}
if (ENCODED_HTML_ENTITY_RE.test(value)) {
issues.push(`${catalogName}: ${prefix} contains an encoded HTML entity`)
}
@@ -138,6 +179,20 @@ function sameValues(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function occurrenceCount(value, literal) {
return value.split(literal).length - 1
}
function verifyProtectedLiterals(englishValue, localeValue, locale, 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}`)
}
}
return issues
}
export function collectMobileTranslationCalls(filePath, sourceText, root = process.cwd()) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
@@ -216,15 +271,129 @@ function verifyLocaleEntries(englishEntries, locale, localeEntries) {
continue
}
const englishPlaceholders = collectPlaceholderNames(englishValue)
const localePlaceholders = collectPlaceholderNames(localeEntries.get(key))
const localeValue = localeEntries.get(key)
const localePlaceholders = collectPlaceholderNames(localeValue)
if (!sameValues(englishPlaceholders, localePlaceholders)) {
issues.push(`${locale}.json placeholder mismatch: ${key}`)
}
issues.push(...verifyProtectedLiterals(englishValue, localeValue, locale, key))
}
return issues
}
function findPluginOptions(expoConfig, pluginName) {
const plugin = expoConfig.plugins?.find(
(entry) => Array.isArray(entry) && entry[0] === pluginName
)
return Array.isArray(plugin) && typeof plugin[1] === 'object' && plugin[1] !== null
? plugin[1]
: {}
}
function verifyNativeFallbacks(expoConfig, englishEntries) {
const camera = findPluginOptions(expoConfig, 'expo-camera')
const imagePicker = findPluginOptions(expoConfig, 'expo-image-picker')
const infoPlist = expoConfig.ios?.infoPlist ?? {}
const fallbackValues = new Map([
['ios.CFBundleDisplayName', [expoConfig.name]],
['ios.NSCameraUsageDescription', [camera.cameraPermission]],
['ios.NSLocalNetworkUsageDescription', [infoPlist.NSLocalNetworkUsageDescription]],
[
'ios.NSMicrophoneUsageDescription',
[infoPlist.NSMicrophoneUsageDescription, camera.microphonePermission]
],
[
'ios.NSPhotoLibraryUsageDescription',
[infoPlist.NSPhotoLibraryUsageDescription, imagePicker.photosPermission]
],
['android.app_name', [expoConfig.name]]
])
const issues = []
for (const [key, values] of fallbackValues) {
const englishValue = englishEntries.get(key)
for (const value of values) {
if (typeof value !== 'string' || value !== englishValue) {
issues.push(`mobile/app.json English native fallback mismatch: ${key}`)
break
}
}
}
return issues
}
async function verifyNativeCatalogs(root) {
const issues = []
const appConfigPath = path.join(root, 'mobile', 'app.json')
let appConfig
try {
appConfig = JSON.parse(await fs.readFile(appConfigPath, 'utf8'))
} catch (error) {
return [
`${normalizePath(root, appConfigPath)} could not be read: ${error instanceof Error ? error.message : String(error)}`
]
}
const expoConfig = appConfig.expo ?? {}
const configuredLocales = expoConfig.locales ?? {}
for (const [locale, expectedPath] of NATIVE_LOCALE_PATHS) {
if (configuredLocales[locale] !== expectedPath) {
issues.push(`mobile/app.json locale ${locale} must map to ${expectedPath}`)
}
}
for (const locale of Object.keys(configuredLocales)) {
if (!NATIVE_LOCALE_PATHS.has(locale)) {
issues.push(`mobile/app.json has unsupported native locale mapping: ${locale}`)
}
}
const localizationPlugin = findPluginOptions(expoConfig, 'expo-localization')
if (!sameValues(localizationPlugin.supportedLocales ?? [], NATIVE_LOCALES)) {
issues.push('mobile/app.json expo-localization supportedLocales mismatch')
}
for (const [runtimeLocale, nativeLocale] of RUNTIME_TO_NATIVE_LOCALE) {
if (!MOBILE_LOCALES.includes(runtimeLocale) || !NATIVE_LOCALE_PATHS.has(nativeLocale)) {
issues.push(`mobile locale mapping is incomplete: ${runtimeLocale} -> ${nativeLocale}`)
}
}
const nativeCatalogs = new Map()
for (const locale of NATIVE_LOCALES) {
const catalogPath = path.join(root, NATIVE_LOCALES_RELATIVE_DIR, `${locale}.json`)
try {
const flattened = flattenCatalog(
JSON.parse(await fs.readFile(catalogPath, 'utf8')),
`mobile/locales/${locale}.json`
)
nativeCatalogs.set(locale, flattened.entries)
issues.push(...flattened.issues)
} catch (error) {
issues.push(
`${normalizePath(root, catalogPath)} could not be read: ${error instanceof Error ? error.message : String(error)}`
)
}
}
for (const [locale, entries] of nativeCatalogs) {
for (const key of NATIVE_REQUIRED_KEYS) {
if (!entries.has(key)) {
issues.push(`mobile/locales/${locale}.json missing required native key: ${key}`)
}
}
for (const key of entries.keys()) {
if (!NATIVE_REQUIRED_KEYS.includes(key)) {
issues.push(`mobile/locales/${locale}.json has unsupported native key: ${key}`)
}
}
}
const englishEntries = nativeCatalogs.get('en')
if (englishEntries) {
issues.push(...verifyNativeFallbacks(expoConfig, englishEntries))
}
return issues
}
async function readCatalogs(root) {
const localeDirectory = path.join(root, LOCALES_RELATIVE_DIR)
const catalogs = new Map()
@@ -259,6 +428,7 @@ function reportIssues(issues) {
export async function main(root = process.cwd()) {
const { catalogs, issues } = await readCatalogs(root)
issues.push(...(await verifyNativeCatalogs(root)))
const englishEntries = catalogs.get('en')
if (!englishEntries) {
reportIssues(issues)
@@ -7,23 +7,69 @@ import { describe, expect, it, vi } from 'vitest'
import { main as verifyMobileCatalog } from './verify-mobile-localization-catalog.mjs'
const LOCALES = ['en', 'es', 'ja', 'ko', 'zh']
const NATIVE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh-Hans']
const NATIVE_CATALOG = {
ios: {
CFBundleDisplayName: 'Orca',
NSCameraUsageDescription: 'Camera fallback',
NSLocalNetworkUsageDescription: 'Network fallback',
NSMicrophoneUsageDescription: 'Microphone fallback',
NSPhotoLibraryUsageDescription: 'Photo fallback'
},
android: { app_name: 'Orca' }
}
function defaultAppConfig() {
return {
expo: {
name: 'Orca',
locales: Object.fromEntries(
NATIVE_LOCALES.map((locale) => [locale, `./locales/${locale}.json`])
),
ios: {
infoPlist: {
NSLocalNetworkUsageDescription: 'Network fallback',
NSMicrophoneUsageDescription: 'Microphone fallback',
NSPhotoLibraryUsageDescription: 'Photo fallback'
}
},
plugins: [
['expo-localization', { supportedLocales: NATIVE_LOCALES }],
[
'expo-camera',
{ cameraPermission: 'Camera fallback', microphonePermission: 'Microphone fallback' }
],
['expo-image-picker', { photosPermission: 'Photo fallback' }]
]
}
}
}
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function makeProject({ sourceText, catalogs }) {
function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) {
const root = mkdtempSync(path.join(tmpdir(), 'orca-mobile-localization-'))
const appDirectory = path.join(root, 'mobile', 'app')
const sourceDirectory = path.join(root, 'mobile', 'src')
const localeDirectory = path.join(sourceDirectory, 'i18n', 'locales')
const nativeLocaleDirectory = path.join(root, 'mobile', 'locales')
mkdirSync(appDirectory, { recursive: true })
mkdirSync(localeDirectory, { recursive: true })
mkdirSync(nativeLocaleDirectory, { recursive: true })
writeFileSync(path.join(appDirectory, 'Example.tsx'), sourceText, 'utf8')
writeJson(path.join(root, 'mobile', 'app.json'), appConfig ?? defaultAppConfig())
for (const locale of LOCALES) {
writeJson(path.join(localeDirectory, `${locale}.json`), catalogs?.[locale] ?? catalogs.en)
}
for (const locale of NATIVE_LOCALES) {
writeJson(
path.join(nativeLocaleDirectory, `${locale}.json`),
nativeCatalogs?.[locale] ?? NATIVE_CATALOG
)
}
return root
}
@@ -120,4 +166,60 @@ describe('verify-mobile-localization-catalog', () => {
'en.json: m.label contains an encoded HTML entity'
)
})
it('rejects empty target translations before i18next can render a blank label', async () => {
const root = makeProject({
sourceText: "export const label = 'not rendered'\n",
catalogs: { en: { m: { label: 'Visible' } }, es: { m: { label: ' ' } } }
})
expect(await runFailedVerification(root)).toContain('es.json: m.label must not be empty')
})
it('rejects translated technical literals and commands', async () => {
const root = makeProject({
sourceText: "export const label = 'not rendered'\n",
catalogs: {
en: { m: { command: 'Run pnpm build from orca.yaml' } },
es: { m: { command: 'Ejecuta compilación pnpm desde Orca.yaml' } }
}
})
const report = await runFailedVerification(root)
expect(report).toContain('must preserve pnpm build')
expect(report).toContain('must preserve orca.yaml')
})
it('validates native locale paths, zh-Hans mapping, and required keys', async () => {
const appConfig = defaultAppConfig()
appConfig.expo.locales['zh-Hans'] = './locales/zh.json'
const root = makeProject({
sourceText: "export const label = 'not rendered'\n",
catalogs: { en: { m: { label: 'Visible' } } },
nativeCatalogs: {
es: { ios: { ...NATIVE_CATALOG.ios, NSCameraUsageDescription: undefined } }
},
appConfig
})
const report = await runFailedVerification(root)
expect(report).toContain('locale zh-Hans must map to ./locales/zh-Hans.json')
expect(report).toContain(
'mobile/locales/es.json missing required native key: ios.NSCameraUsageDescription'
)
})
it('keeps app-config fallbacks aligned with the English native catalog', async () => {
const appConfig = defaultAppConfig()
appConfig.expo.plugins[1][1].cameraPermission = 'Different fallback'
const root = makeProject({
sourceText: "export const label = 'not rendered'\n",
catalogs: { en: { m: { label: 'Visible' } } },
appConfig
})
expect(await runFailedVerification(root)).toContain(
'mobile/app.json English native fallback mismatch: ios.NSCameraUsageDescription'
)
})
})
+2 -2
View File
@@ -26,7 +26,7 @@
"buildNumber": "1",
"infoPlist": {
"CFBundleAllowMixedLocalizations": true,
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your LAN.",
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
"NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
"NSAppTransportSecurity": {
@@ -105,7 +105,7 @@
"expo-camera",
{
"cameraPermission": "Allow Orca to use the camera for QR code scanning.",
"microphonePermission": "Allow Orca to use your microphone for voice dictation sent to your paired desktop for transcription.",
"microphonePermission": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
"recordAudioAndroid": false
}
],
@@ -1,4 +1,6 @@
import { colors } from '../theme/mobile-theme'
import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy'
import { t } from '../i18n/mobile-i18n'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
export function escapeInjectedJavaScriptString(value: string): string {
@@ -204,7 +206,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
</style>
</head>
<body>
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
<main id="editor" contenteditable="true" data-placeholder="${escapeEmbeddedHtmlCopy(t('richMarkdown.startWriting'))}"></main>
<script>
(function () {
var editor = document.getElementById('editor');
@@ -618,13 +620,13 @@ export function buildMobileRichMarkdownEditorHtml(): string {
else if (command === 'orderedList') document.execCommand('insertOrderedList');
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
else if (command === 'inlineCode') wrapSelection('code');
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>' + ${escapeInjectedJavaScriptString(t('richMarkdown.codePlaceholder'))} + '</code></pre><p><br></p>');
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>' + ${escapeInjectedJavaScriptString(t('richMarkdown.taskPlaceholder'))} + '</p></div></li></ul>');
else if (command === 'link') {
var href = window.prompt('Link URL');
var href = window.prompt(${escapeInjectedJavaScriptString(t('richMarkdown.linkUrlPrompt'))});
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
} else if (command === 'image') {
var src = window.prompt('Image URL');
var src = window.prompt(${escapeInjectedJavaScriptString(t('richMarkdown.imageUrlPrompt'))});
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
}
syncTaskCheckboxesDisabled();
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { PRCheckDetail } from '../../../../src/shared/types'
import { mobileI18n } from '../../i18n/mobile-i18n'
import {
checkOutcome,
checkStatusLabel,
@@ -21,6 +22,12 @@ function check(over: Partial<PRCheckDetail>): PRCheckDetail {
}
}
const INITIAL_LOCALE = mobileI18n.language
afterEach(async () => {
await mobileI18n.changeLanguage(INITIAL_LOCALE)
})
describe('checkOutcome', () => {
it('treats a completed null-conclusion check as neutral, not failure', () => {
expect(checkOutcome(check({ status: 'completed', conclusion: null }))).toBe('neutral')
@@ -142,6 +149,18 @@ describe('summarizePRChecks', () => {
expect(summary).toMatchObject({ total: 3, passed: 3, outcome: 'success' })
expect(summary.label).toBe('3 passed')
})
it('uses localized plural-aware check summary fragments', async () => {
await mobileI18n.changeLanguage('es')
const summary = summarizePRChecks([
check({ conclusion: 'success' }),
check({ conclusion: 'success' }),
check({ status: 'in_progress', conclusion: null }),
check({ conclusion: 'failure' })
])
expect(summary.label).toBe('1 con errores · 1 pendiente · 2 aprobadas')
})
})
describe('prCheckKey', () => {
@@ -81,16 +81,32 @@ export function summarizePRChecks(checks: readonly PRCheckDetail[]): PRChecksSum
const outcome = OUTCOME_BY_STATE[state]
const parts: string[] = []
if (failed > 0) {
parts.push(`${failed} failing`)
parts.push(
t(failed === 1 ? 'prChecks.summary.failing.one' : 'prChecks.summary.failing.other', {
count: failed
})
)
}
if (pending > 0) {
parts.push(`${pending} pending`)
parts.push(
t(pending === 1 ? 'prChecks.summary.pending.one' : 'prChecks.summary.pending.other', {
count: pending
})
)
}
if (passed > 0) {
parts.push(`${passed} passed`)
parts.push(
t(passed === 1 ? 'prChecks.summary.passed.one' : 'prChecks.summary.passed.other', {
count: passed
})
)
}
if (neutral > 0) {
parts.push(`${neutral} neutral`)
parts.push(
t(neutral === 1 ? 'prChecks.summary.neutral.one' : 'prChecks.summary.neutral.other', {
count: neutral
})
)
}
return {
total,
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { buildMobileRichMarkdownEditorHtml } from '../components/mobile-rich-markdown-editor-html'
import { buildTerminalWebViewHtml } from '../terminal/terminal-webview-html'
import { escapeEmbeddedHtmlCopy } from './embedded-webview-copy'
import { mobileI18n } from './mobile-i18n'
const INITIAL_LOCALE = mobileI18n.language
afterEach(async () => {
await mobileI18n.changeLanguage(INITIAL_LOCALE)
})
describe('embedded WebView copy', () => {
it('escapes translated HTML text and attributes', () => {
expect(escapeEmbeddedHtmlCopy('<Copy "all" & more>')).toBe(
'&lt;Copy &quot;all&quot; &amp; more&gt;'
)
})
it('injects localized rich-editor and terminal controls', async () => {
await mobileI18n.changeLanguage('es')
const editorHtml = buildMobileRichMarkdownEditorHtml()
expect(editorHtml).toContain('data-placeholder="Empieza a escribir..."')
expect(editorHtml).toContain('window.prompt("URL del enlace")')
expect(editorHtml).toContain('window.prompt("URL de la imagen")')
expect(editorHtml).toContain(`+ "Tarea" +`)
const terminalHtml = buildTerminalWebViewHtml()
expect(terminalHtml).toContain('id="sel-menu-copy">Copiar</button>')
expect(terminalHtml).toContain('id="sel-menu-all">Seleccionar todo</button>')
})
})
+8
View File
@@ -0,0 +1,8 @@
export function escapeEmbeddedHtmlCopy(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
+32 -1
View File
@@ -2180,7 +2180,38 @@
"status": {
"actionRequired": "Action required"
},
"annotationFallback": "Annotation"
"annotationFallback": "Annotation",
"summary": {
"failing": {
"one": "{{count}} failing",
"other": "{{count}} failing"
},
"pending": {
"one": "{{count}} pending",
"other": "{{count}} pending"
},
"passed": {
"one": "{{count}} passed",
"other": "{{count}} passed"
},
"neutral": {
"one": "{{count}} neutral",
"other": "{{count}} neutral"
}
}
},
"richMarkdown": {
"startWriting": "Start writing...",
"codePlaceholder": "code",
"taskPlaceholder": "Task",
"linkUrlPrompt": "Link URL",
"imageUrlPrompt": "Image URL"
},
"terminal": {
"selection": {
"copy": "Copy",
"selectAll": "Select All"
}
},
"session": {
"quickCommand": {
+46 -13
View File
@@ -87,7 +87,7 @@
"mZj5XBY": "No se puede cargar la diferencia",
"v6-Hc8I": "Cerrar vista previa de diferencias comprometidas",
"I6GjQO8": "Comprometidos en sucursal",
"6lym-WQ": "{{value0}}..CABEZA",
"6lym-WQ": "{{value0}}..HEAD",
"P2fbbug": "Mostrar detalles",
"eysP-fI": "Ocultar detalles",
"6Pb0W84": "Mostrar detalles del error de commit",
@@ -343,7 +343,7 @@
"UfU4CAU": "Vista previa binaria no disponible",
"2t0lh6U": "No se puede cargar la vista previa",
"jLhPXXQ": "Otra interferencia de VPN",
"ngjn4tY": "Host de escala final inalcanzable",
"ngjn4tY": "Host de Tailscale inaccesible",
"AE3mc7A": "Tiempo de espera de conexión",
"MED_8yU": "La aplicación de escritorio no se ejecuta",
"amY3iCs": "Puerto de bloqueo de firewall 6768",
@@ -524,7 +524,7 @@
"wOCs5LA": "Ejecutar hooks",
"m7jxYm8": "Script de configuración",
"gxXtGpI": "Nuevo script de configuración",
"JU7DVfY": "Orca.yaml de este repositorio se ejecuta antes de que comience el espacio de trabajo. Ejecútelo solo si confía en este repositorio.",
"JU7DVfY": "El orca.yaml de este repositorio se ejecuta antes de que comience el espacio de trabajo. Ejecútelo solo si confía en este repositorio.",
"SSMlLr8": "¿Ejecutar la configuración desde {{value0}}?",
"egQ-qgQ": "El script de configuración de {{value0}} cambió",
"Lkm1Ms4": "Derivado del nombre",
@@ -774,14 +774,14 @@
"Au06U2c": "Historial de sesiones de Agents",
"XjyxBLA": "Control de código fuente",
"pBOdX2k": "@orca_build",
"qfIlnqY": "estableai/orca",
"1sjQKuE": "enOrca.dev",
"qfIlnqY": "stablyai/orca",
"1sjQKuE": "onOrca.dev",
"wRf8z4A": "IDE de agent de código abierto para constructores 100x",
"o1zkACA": "Orca",
"qBqOdtg": "Acerca de",
"wdyLCXU": "Abrir enlaces de terminales",
"K6VU-6c": "Elija dónde se abren los enlaces HTTP(S) seleccionados en la salida del terminal.",
"gh7VaTc": "CAMPO DE GOLF",
"gh7VaTc": "ENLACES",
"GJwMNak": "Navegador",
"Wj6QRnE": "Ábralo en Safari, Chrome u otro navegador en este teléfono.",
"hIo6XgE": "Navegador de teléfono",
@@ -1110,7 +1110,7 @@
"m-fVevQ": "Ejecutar hooks",
"XPutx64": "Script de configuración",
"vtdIQWU": "Nuevo script de configuración",
"Q3oUbk4": "Orca.yaml de este repositorio se ejecuta en su máquina antes de que comience el espacio de trabajo. Ejecútelo solo si confía en este repositorio.",
"Q3oUbk4": "El orca.yaml de este repositorio se ejecuta en su máquina antes de que comience el espacio de trabajo. Ejecútelo solo si confía en este repositorio.",
"M_Pqsow": "¿Ejecutar la configuración desde {{value0}}?",
"QetdCKc": "El script de configuración de {{value0}} cambió",
"xd1VuYs": "Saltar configuración y crear",
@@ -1454,7 +1454,7 @@
"76ELAmA": "Vista previa",
"dZsgG-M": "No se puede cargar la carpeta",
"wA_0d2E": "Restablecimiento del límite de velocidad del Codex obtenido",
"CiFc9DQ": "p.ej. compilación pnpm",
"CiFc9DQ": "p. ej. pnpm build",
"d0JpJz4": "p.ej. Construir",
"rMV0BMU": "Elige una llave",
"fTbhbgg": "Abrir imagen",
@@ -1615,7 +1615,7 @@
"V4EVATY": "Cambiado en el escritorio",
"RwIU_9I": "Solo lectura",
"hPnnpWc": "Directo · LAN",
"f3Si3vU": "Directo · Escala trasera",
"f3Si3vU": "Directo · Tailscale",
"Lo_8ioI": "Orca Relay",
"QNvTsRo": "Sin {{value0}}",
"nDyX2e8": "Terminal en blanco",
@@ -1721,7 +1721,7 @@
"OLmV45U": "Sin título",
"S-avV-Y": "{{value0}} archivo revisado añadido al staging",
"IPu47z8": "{{value0}} archivos revisados añadidos al staging",
"TxG95wU": "comprobar escala trasera",
"TxG95wU": "Comprobar Tailscale",
"oBMMuM8": "Los elementos de trabajo de GitHub requieren un control remoto de GitHub para repositorios SSH",
"m4LECc4": "Espacio de trabajo flotante",
"ueRRwRg": "La conexión es demasiado lenta para el dictado por voz. Inténtalo de nuevo cuando la conexión mejore.",
@@ -1857,7 +1857,7 @@
"Gli-J-E": "No se pudo leer el estado de la conexión SSH.",
"17Ky5K0": "Linear",
"kWDpsSU": "Asunto #",
"G_t8Rjg": "SEÑOR !",
"G_t8Rjg": "MR !",
"Jq988q0": "PR #",
"a6UgDc8": "Diálogo del navegador",
"VgO3m0E": "Sesión sin título",
@@ -2145,7 +2145,7 @@
"hRiZhQg": "No se pueden cargar los metadatos del espacio de trabajo.",
"THbZzp4": "No se puede contactar con el anfitrión",
"MJ1SglY": "Deshabilite esa VPN o habilite el túnel dividido / \"Permitir LAN\".",
"n-WXtiA": "Las VPN que no son de escala final pueden enrutar el tráfico local a través de un servidor remoto.",
"n-WXtiA": "Las VPN que no son de Tailscale pueden enrutar el tráfico local a través de un servidor remoto.",
"PxeuBQc": "Actualice la aplicación Tailscale: las versiones recientes corrigen errores de reconexión.",
"j0KeldQ": "Verifique que el escritorio esté activo y se muestre como conectado en su tailnet.",
"rmMn9Xc": "iOS/Android puede bloquear silenciosamente el túnel: desactive y vuelva a activar Tailscale en la aplicación Tailscale.",
@@ -2157,7 +2157,7 @@
"WkJ-I-g": "Intente reiniciar Orca: el servidor complementario se inicia al iniciarse.",
"i4fV_jk": "Orca debe estar abierto en su escritorio para aceptar conexiones.",
"rtC8ngI": "Las redes corporativas/escolares pueden bloquear el P2P; pruebe con un punto de acceso personal.",
"401zg0M": "Linux: sudo ufw permite 6768",
"401zg0M": "Linux: sudo ufw allow 6768",
"ayekp1A": "Windows: Defender Firewall → Permitir aplicación: habilite Orca para redes privadas.",
"REilmMs": "macOS: Configuración del sistema → Red → Firewall: permitir Orca.",
"jEL8NQc": "Intente volver a conectar WiFi en ambos dispositivos.",
@@ -2175,5 +2175,38 @@
"39Q08OU": "{{value0}} revisor",
"d8cyKVY": "{{value0}} revisores",
"UMqLjMs": "Comprobaciones sin resolver"
},
"prChecks": {
"summary": {
"failing": {
"one": "{{count}} con errores",
"other": "{{count}} con errores"
},
"pending": {
"one": "{{count}} pendiente",
"other": "{{count}} pendientes"
},
"passed": {
"one": "{{count}} aprobada",
"other": "{{count}} aprobadas"
},
"neutral": {
"one": "{{count}} neutral",
"other": "{{count}} neutrales"
}
}
},
"richMarkdown": {
"startWriting": "Empieza a escribir...",
"codePlaceholder": "código",
"taskPlaceholder": "Tarea",
"linkUrlPrompt": "URL del enlace",
"imageUrlPrompt": "URL de la imagen"
},
"terminal": {
"selection": {
"copy": "Copiar",
"selectAll": "Seleccionar todo"
}
}
}
+43 -10
View File
@@ -87,7 +87,7 @@
"mZj5XBY": "差分をロードできません",
"v6-Hc8I": "コミットされた差分プレビューを閉じる",
"I6GjQO8": "ブランチにコミット",
"6lym-WQ": "{{value0}}..頭",
"6lym-WQ": "{{value0}}..HEAD",
"P2fbbug": "詳細を表示",
"eysP-fI": "詳細を非表示",
"6Pb0W84": "commit 失敗の詳細を表示する",
@@ -221,7 +221,7 @@
"S9UUSi8": "追加 Enter",
"MsJOHDg": "詳細設定",
"WFigoqY": "スキル、ファイル パス、組み込みコマンドをサポートします。",
"YGxS8ck": "npm 実行開発",
"YGxS8ck": "npm run dev",
"j_uWjuQ": "agent にこのワークスペースを調査するよう依頼します",
"2WUowpg": "コマンドテキスト",
"v512Bqc": "プロンプト",
@@ -343,7 +343,7 @@
"UfU4CAU": "バイナリ プレビューは利用できません",
"2t0lh6U": "プレビューを読み込めません",
"jLhPXXQ": "その他の VPN 干渉",
"ngjn4tY": "テールスケール ホストに到達できません",
"ngjn4tY": "Tailscale ホストに到達できません",
"AE3mc7A": "接続タイムアウト",
"MED_8yU": "デスクトップアプリが実行されていない",
"amY3iCs": "ファイアウォールがポート 6768 をブロックしている",
@@ -774,7 +774,7 @@
"Au06U2c": "Agent セッション履歴",
"XjyxBLA": "ソース管理",
"pBOdX2k": "@orca_build",
"qfIlnqY": "スタビライ/シャチ",
"qfIlnqY": "stablyai/orca",
"1sjQKuE": "onOrca.dev",
"wRf8z4A": "100x ビルダー向けのオープンソース agent IDE",
"o1zkACA": "Orca",
@@ -1454,7 +1454,7 @@
"76ELAmA": "プレビュー",
"dZsgG-M": "フォルダーをロードできません",
"wA_0d2E": "獲得した Codex レート制限のリセット",
"CiFc9DQ": "例えばpnpmビルド",
"CiFc9DQ": "例: pnpm build",
"d0JpJz4": "例えば建てる",
"rMV0BMU": "キーを選択してください",
"fTbhbgg": "画像を開く",
@@ -1615,7 +1615,7 @@
"V4EVATY": "デスクトップで変更されました",
"RwIU_9I": "読み取り専用",
"hPnnpWc": "ダイレクト・LAN",
"f3Si3vU": "ダイレクト・テールスケール",
"f3Si3vU": "ダイレクト · Tailscale",
"Lo_8ioI": "Orca Relay",
"QNvTsRo": "いいえ{{value0}}",
"nDyX2e8": "ブランク Terminal",
@@ -1721,7 +1721,7 @@
"OLmV45U": "無題",
"S-avV-Y": "レビュー済みファイル {{value0}} 件をステージしました",
"IPu47z8": "レビュー済みファイル {{value0}} 件をステージしました",
"TxG95wU": "テールスケールをチェックする",
"TxG95wU": "Tailscale を確認",
"oBMMuM8": "GitHub ワークアイテムには SSH リポジトリ用の GitHub リモートが必要です",
"m4LECc4": "フローティングワークスペース",
"ueRRwRg": "接続が遅すぎて音声ディクテーションができません。接続が改善されてからもう一度お試しください。",
@@ -1857,7 +1857,7 @@
"Gli-J-E": "SSH 接続状態の読み取りに失敗しました。",
"17Ky5K0": "Linear",
"kWDpsSU": "問題 #",
"G_t8Rjg": "氏 !",
"G_t8Rjg": "MR !",
"Jq988q0": "PR 番号",
"a6UgDc8": "ブラウザダイアログ",
"VgO3m0E": "無題のセッション",
@@ -2145,7 +2145,7 @@
"hRiZhQg": "ワークスペースのメタデータをロードできません。",
"THbZzp4": "ホストにアクセスできません",
"MJ1SglY": "その VPN を無効にするか、スプリット トンネリング / 「LAN を許可」を有効にします。",
"n-WXtiA": "非テールスケール VPN は、ローカル トラフィックをリモート サーバー経由でルーティングできます。",
"n-WXtiA": "Tailscale 以外の VPN は、ローカル トラフィックをリモート サーバー経由でルーティングできます。",
"PxeuBQc": "Tailscale アプリを更新します。最近のリリースでは再接続のバグが修正されています。",
"j0KeldQ": "デスクトップが起動しており、テールネットに接続されていることが表示されていることを確認します。",
"rmMn9Xc": "iOS/Android はサイレントにトンネルを遮断できます。Tailscale アプリで Tailscale をオフにしてからオンに戻します。",
@@ -2157,7 +2157,7 @@
"WkJ-I-g": "Orca を再起動してみてください。起動時にコンパニオン サーバーが開始されます。",
"i4fV_jk": "接続を受け入れるには、デスクトップ上で Orca が開いている必要があります。",
"rtC8ngI": "企業/学校のネットワークは P2P をブロックする可能性があります。個人用ホットスポットを試す。",
"401zg0M": "Linux: sudo ufw 許可 6768",
"401zg0M": "Linux: sudo ufw allow 6768",
"ayekp1A": "Windows: Defender ファイアウォール → アプリを許可 — プライベート ネットワークに対して Orca を有効にします。",
"REilmMs": "macOS: システム設定 → ネットワーク → ファイアウォール — Orca を許可します。",
"jEL8NQc": "両方のデバイスで WiFi を再接続してみてください。",
@@ -2175,5 +2175,38 @@
"39Q08OU": "レビュアー {{value0}} 人",
"d8cyKVY": "レビュアー {{value0}} 人",
"UMqLjMs": "未解決のチェック"
},
"prChecks": {
"summary": {
"failing": {
"one": "{{count}} 件失敗",
"other": "{{count}} 件失敗"
},
"pending": {
"one": "{{count}} 件保留中",
"other": "{{count}} 件保留中"
},
"passed": {
"one": "{{count}} 件成功",
"other": "{{count}} 件成功"
},
"neutral": {
"one": "{{count}} 件中立",
"other": "{{count}} 件中立"
}
}
},
"richMarkdown": {
"startWriting": "入力を開始...",
"codePlaceholder": "コード",
"taskPlaceholder": "タスク",
"linkUrlPrompt": "リンク URL",
"imageUrlPrompt": "画像 URL"
},
"terminal": {
"selection": {
"copy": "コピー",
"selectAll": "すべて選択"
}
}
}
+40 -7
View File
@@ -774,14 +774,14 @@
"Au06U2c": "Agent 세션 기록",
"XjyxBLA": "소스 제어",
"pBOdX2k": "@orca_build",
"qfIlnqY": "안정/오르카",
"qfIlnqY": "stablyai/orca",
"1sjQKuE": "onOrca.dev",
"wRf8z4A": "100x 빌더를 위한 오픈 소스 agent IDE",
"o1zkACA": "Orca",
"qBqOdtg": "에 대한",
"wdyLCXU": "terminal 링크 열기",
"K6VU-6c": "terminal 출력에서 ​​탭한 HTTP(S) 링크가 열리는 위치를 선택합니다.",
"gh7VaTc": "모래밭",
"gh7VaTc": "링크",
"GJwMNak": "브라우저",
"Wj6QRnE": "이 휴대전화의 Safari, Chrome 또는 다른 브라우저에서 엽니다.",
"hIo6XgE": "전화 브라우저",
@@ -1454,7 +1454,7 @@
"76ELAmA": "미리보기",
"dZsgG-M": "폴더를 로드할 수 없습니다.",
"wA_0d2E": "Codex 비율 제한 재설정을 획득했습니다.",
"CiFc9DQ": "예를 들어 pnpm 빌드",
"CiFc9DQ": "예: pnpm build",
"d0JpJz4": "예를 들어 짓다",
"rMV0BMU": "키를 선택하세요",
"fTbhbgg": "이미지 열기",
@@ -1615,7 +1615,7 @@
"V4EVATY": "데스크탑에서 변경됨",
"RwIU_9I": "읽기 전용",
"hPnnpWc": "직접 · LAN",
"f3Si3vU": "다이렉트·테일스케일",
"f3Si3vU": "다이렉트 · Tailscale",
"Lo_8ioI": "Orca Relay",
"QNvTsRo": "아니요 {{value0}}",
"nDyX2e8": "빈 Terminal",
@@ -1721,7 +1721,7 @@
"OLmV45U": "제목 없음",
"S-avV-Y": "검토된 파일 {{value0}}개 스테이징됨",
"IPu47z8": "검토된 파일 {{value0}}개 스테이징됨",
"TxG95wU": "테일스케일을 확인하세요",
"TxG95wU": "Tailscale을 확인하세요",
"oBMMuM8": "GitHub 작업 항목에는 SSH 저장소용 GitHub 원격이 필요합니다.",
"m4LECc4": "플로팅 워크스페이스",
"ueRRwRg": "음성 받아쓰기를 하기에는 연결 속도가 너무 느립니다. 연결 상태가 좋아지면 다시 시도하세요.",
@@ -1857,7 +1857,7 @@
"Gli-J-E": "SSH 연결 상태를 읽지 못했습니다.",
"17Ky5K0": "Linear",
"kWDpsSU": "이슈 #",
"G_t8Rjg": "씨 !",
"G_t8Rjg": "MR !",
"Jq988q0": "PR 번호",
"a6UgDc8": "브라우저 대화상자",
"VgO3m0E": "제목 없는 세션",
@@ -2157,7 +2157,7 @@
"WkJ-I-g": "Orca를 다시 시작해 보세요. 실행 시 컴패니언 서버가 시작됩니다.",
"i4fV_jk": "연결을 수락하려면 데스크톱에서 Orca를 열어야 합니다.",
"rtC8ngI": "회사/학교 네트워크는 P2P를 차단할 수 있습니다. 개인 핫스팟을 사용해 보세요.",
"401zg0M": "리눅스: sudo ufw 허용 6768",
"401zg0M": "Linux: sudo ufw allow 6768",
"ayekp1A": "Windows: Defender Firewall → 앱 허용 — 개인 네트워크용 Orca를 활성화합니다.",
"REilmMs": "macOS: 시스템 설정 → 네트워크 → 방화벽 — Orca를 허용합니다.",
"jEL8NQc": "두 장치 모두에서 Wi-Fi를 다시 연결해 보세요.",
@@ -2175,5 +2175,38 @@
"39Q08OU": "리뷰어 {{value0}}명",
"d8cyKVY": "리뷰어 {{value0}}명",
"UMqLjMs": "해결되지 않은 체크"
},
"prChecks": {
"summary": {
"failing": {
"one": "실패 {{count}}개",
"other": "실패 {{count}}개"
},
"pending": {
"one": "대기 중 {{count}}개",
"other": "대기 중 {{count}}개"
},
"passed": {
"one": "통과 {{count}}개",
"other": "통과 {{count}}개"
},
"neutral": {
"one": "중립 {{count}}개",
"other": "중립 {{count}}개"
}
}
},
"richMarkdown": {
"startWriting": "입력을 시작하세요...",
"codePlaceholder": "코드",
"taskPlaceholder": "작업",
"linkUrlPrompt": "링크 URL",
"imageUrlPrompt": "이미지 URL"
},
"terminal": {
"selection": {
"copy": "복사",
"selectAll": "모두 선택"
}
}
}
+39 -6
View File
@@ -579,7 +579,7 @@
"EIhMdN0": "主机速率限制目标无法命名 WSL 发行版",
"zFyydp8": "此帐户没有可用的重置积分。",
"_-grAdc": "无法重置",
"Um-XfOo": "符合条件的食典速率限制窗口未用尽。",
"Um-XfOo": "符合条件的 Codex 速率限制窗口未用尽。",
"JMEauuw": "没有什么可以重置的",
"3qHVa1w": "Codex 用法已刷新。",
"9b3h0Pg": "重置已应用",
@@ -774,7 +774,7 @@
"Au06U2c": "Agent 会话历史",
"XjyxBLA": "源代码控制",
"pBOdX2k": "@orca_build",
"qfIlnqY": "斯特布利艾/逆戟鲸",
"qfIlnqY": "stablyai/orca",
"1sjQKuE": "onOrca.dev",
"wRf8z4A": "适用于 100x 构建器的开源 agent IDE",
"o1zkACA": "Orca",
@@ -1452,7 +1452,7 @@
"76ELAmA": "预览",
"dZsgG-M": "无法加载文件夹",
"wA_0d2E": "已获得 Codex 速率限制重置",
"CiFc9DQ": "例如PNPM 构建",
"CiFc9DQ": "例如:pnpm build",
"d0JpJz4": "例如建造",
"rMV0BMU": "选择一把钥匙",
"fTbhbgg": "打开图片",
@@ -1718,7 +1718,7 @@
"OLmV45U": "无题",
"S-avV-Y": "已暂存 {{value0}} 个已审查文件",
"IPu47z8": "已暂存 {{value0}} 个已审查文件",
"TxG95wU": "检查尾标",
"TxG95wU": "检查 Tailscale",
"oBMMuM8": "GitHub 工作项需要 SSH 存储库的 GitHub 远程",
"m4LECc4": "浮动工作区",
"ueRRwRg": "连接速度太慢,无法进行语音听写。当连接改善时重试。",
@@ -1854,7 +1854,7 @@
"Gli-J-E": "无法读取 SSH 连接状态。",
"17Ky5K0": "Linear",
"kWDpsSU": "问题 #",
"G_t8Rjg": "先生 !",
"G_t8Rjg": "MR !",
"Jq988q0": "PR 号#",
"a6UgDc8": "浏览器对话框",
"VgO3m0E": "无标题会话",
@@ -2154,7 +2154,7 @@
"WkJ-I-g": "尝试重新启动 Orca — 配套服务器在启动时启动。",
"i4fV_jk": "Orca 必须在桌面上打开才能接受连接。",
"rtC8ngI": "公司/学校网络可能会阻止 P2P — 尝试使用个人热点。",
"401zg0M": "Linux:sudo ufw 允许 6768",
"401zg0M": "Linux: sudo ufw allow 6768",
"ayekp1A": "Windows:Defender 防火墙 → 允许应用程序 — 为专用网络启用 Orca。",
"REilmMs": "macOS:系统设置 → 网络 → 防火墙 — 允许 Orca。",
"jEL8NQc": "尝试在两台设备上重新连接 WiFi。",
@@ -2172,5 +2172,38 @@
"39Q08OU": "{{value0}} 位评审人",
"d8cyKVY": "{{value0}} 位评审人",
"UMqLjMs": "未解决的检查"
},
"prChecks": {
"summary": {
"failing": {
"one": "{{count}} 项失败",
"other": "{{count}} 项失败"
},
"pending": {
"one": "{{count}} 项待处理",
"other": "{{count}} 项待处理"
},
"passed": {
"one": "{{count}} 项通过",
"other": "{{count}} 项通过"
},
"neutral": {
"one": "{{count}} 项中立",
"other": "{{count}} 项中立"
}
}
},
"richMarkdown": {
"startWriting": "开始输入...",
"codePlaceholder": "代码",
"taskPlaceholder": "任务",
"linkUrlPrompt": "链接 URL",
"imageUrlPrompt": "图片 URL"
},
"terminal": {
"selection": {
"copy": "复制",
"selectAll": "全选"
}
}
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-accessory-keys'
import {
buildTerminalShortcutKey,
normalizeShortcutKeyInput,
TERMINAL_ACCESSORY_KEYS
} from './terminal-accessory-keys'
describe('TERMINAL_ACCESSORY_KEYS', () => {
it('sends reverse-tab with a non-repeatable Shift+Tab key', () => {
@@ -98,6 +102,18 @@ describe('TERMINAL_ACCESSORY_KEYS', () => {
bytes: '\x1b ',
accessibilityLabel: 'Alt Space'
})
expect(buildTerminalShortcutKey({ key: ' ', modifiers: ['ctrl'] })).toEqual({
label: 'Ctrl+Space',
bytes: '\x00',
accessibilityLabel: 'Ctrl Space'
})
})
it('normalizes a typed space before the custom-key persistence path builds its label', () => {
const key = normalizeShortcutKeyInput(' ')
expect(key).toBe('space')
expect(buildTerminalShortcutKey({ key: key!, modifiers: ['ctrl'] })?.label).toBe('Ctrl+Space')
})
it('builds modified special-key terminal sequences', () => {
@@ -338,6 +338,9 @@ function normalizeModifiers(modifiers: TerminalShortcutModifier[]): TerminalShor
}
function normalizeShortcutKey(key: string): string | null {
if (key === ' ') {
return 'space'
}
if (SPECIAL_KEY_LABELS[key]) {
return key
}
@@ -360,8 +363,5 @@ function displayKeyLabel(key: string): string {
if (SPECIAL_KEY_LABELS[key]) {
return SPECIAL_KEY_LABELS[key]
}
if (key === ' ') {
return t('m.l6KJT3M')
}
return key.length === 1 && key >= 'a' && key <= 'z' ? key.toUpperCase() : key
}
+9 -3
View File
@@ -1,5 +1,7 @@
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { escapeEmbeddedHtmlCopy } from '../i18n/embedded-webview-copy'
import { t } from '../i18n/mobile-i18n'
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
@@ -48,7 +50,8 @@ export const MOBILE_TERMINAL_CARET_OPTIONS = {
} as const
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `<!DOCTYPE html>
export function buildTerminalWebViewHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@@ -202,8 +205,8 @@ window.onerror = function(msg) {
<div id="sel-handle-start" class="sel-handle start"></div>
<div id="sel-handle-end" class="sel-handle end"></div>
<div id="sel-menu">
<button id="sel-menu-copy">Copy</button>
<button id="sel-menu-all">Select All</button>
<button id="sel-menu-copy">${escapeEmbeddedHtmlCopy(t('terminal.selection.copy'))}</button>
<button id="sel-menu-all">${escapeEmbeddedHtmlCopy(t('terminal.selection.selectAll'))}</button>
</div>
</div>
<script>${XTERM_ENGINE_JS}</script>
@@ -1885,6 +1888,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
</script>
</body>
</html>`
}
export const XTERM_HTML = buildTerminalWebViewHtml()
// Why: some WebViews treat source identity as page identity; keep this stable so re-renders don't reload xterm.
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }