mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge remote-tracking branch 'origin/main' into mobile-rearch
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -6901,13 +6901,14 @@
|
||||
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||
@@ -6925,11 +6926,18 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
|
||||
"assertions": [
|
||||
"synthetic and native input-source paths are classified for covered fixtures"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
|
||||
"assertions": [
|
||||
"an OS key-binding substitution reaches the PTY instead of the raw layout character",
|
||||
"the same key with no substitution in play still sends the layout character"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||
"assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
|
||||
@@ -6982,6 +6990,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.7,
|
||||
"summary": "8 test file(s) passed, 143 tests, after adding the OS key-binding substitution replay. Discrimination checked by mutation rather than assumed: suppressing the structural claim, and separately removing the single character the older punctuation table happened to list, each make the replay send the raw layout character while both paired negatives stay green."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-11",
|
||||
"runner": "local",
|
||||
@@ -7013,10 +7030,10 @@
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.7,
|
||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
|
||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage). The command shown is the gate's current one, which the manifest check requires; the run itself predates two of the files it now lists. The 2026-08-09 entry is the one that covers the current slice."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Per-locale renderings that are machine-translation errors for a brand or product name, and get
|
||||
// reverted to the Latin original. Ordinary vocabulary that merely looks like a brand lives in
|
||||
// locale-generic-ui-terms.mjs and stays translated.
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
ko: {
|
||||
Codex: ['사본', '코덱스'],
|
||||
Gemini: ['쌍둥이자리'],
|
||||
Claude: ['클로드'],
|
||||
Grok: ['그록'],
|
||||
Orca: ['오르카', '범고래'],
|
||||
Cursor: ['커서'],
|
||||
OpenCode: ['오픈코드'],
|
||||
OpenClaw: ['오픈클로'],
|
||||
OpenClaude: ['오픈클로드'],
|
||||
Antigravity: ['반중력'],
|
||||
Continue: ['계속하다'],
|
||||
Charm: ['매력'],
|
||||
Goose: ['거위'],
|
||||
Pi: ['파이'],
|
||||
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
||||
Git: ['힘내'],
|
||||
Discord: ['디스코드'],
|
||||
Linear: ['선형'],
|
||||
Agent: ['에이전트'],
|
||||
Agents: ['에이전트'],
|
||||
agent: ['에이전트'],
|
||||
agents: ['에이전트'],
|
||||
Commit: ['커밋'],
|
||||
Commits: ['커밋'],
|
||||
commit: ['커밋'],
|
||||
commits: ['커밋'],
|
||||
Markdown: ['마크다운', '가격 인하'],
|
||||
markdown: ['마크다운', '가격 인하'],
|
||||
Repo: ['저장소', '레포'],
|
||||
Repos: ['저장소', '레포'],
|
||||
repo: ['저장소', '레포'],
|
||||
repos: ['저장소', '레포'],
|
||||
Terminal: ['터미널'],
|
||||
Terminals: ['터미널'],
|
||||
terminal: ['터미널'],
|
||||
terminals: ['터미널']
|
||||
},
|
||||
zh: {
|
||||
Codex: ['法典'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['克洛德', '克劳德'],
|
||||
Grok: ['格罗克'],
|
||||
Orca: ['虎鲸', '逆戟鲸'],
|
||||
Cursor: ['光标'],
|
||||
OpenCode: ['开放代码'],
|
||||
OpenClaw: ['开爪'],
|
||||
OpenClaude: ['开放克劳德'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['继续'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['鹅'],
|
||||
Pi: ['圆周率'],
|
||||
Droid: ['机器人'],
|
||||
Kimi: ['基米'],
|
||||
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
|
||||
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
|
||||
moonshot: ['登月计划'],
|
||||
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
||||
Bitbucket: ['位桶'],
|
||||
Linear: ['线性', '线形'],
|
||||
Jira: ['吉拉'],
|
||||
Tailscale: ['尾鳞', '尾鱗'],
|
||||
Agent: ['代理', '智能体'],
|
||||
Agents: ['代理', '智能体'],
|
||||
agent: ['代理', '智能体'],
|
||||
agents: ['代理', '智能体'],
|
||||
Commit: ['提交'],
|
||||
Commits: ['提交'],
|
||||
commit: ['提交'],
|
||||
commits: ['提交'],
|
||||
Markdown: ['降价'],
|
||||
markdown: ['降价'],
|
||||
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Terminal: ['终端', '端子'],
|
||||
Terminals: ['终端', '端子'],
|
||||
terminal: ['终端', '端子'],
|
||||
terminals: ['终端', '端子'],
|
||||
Bash: ['重击'],
|
||||
PowerShell: ['电源外壳'],
|
||||
REST: ['休息'],
|
||||
HEAD: ['头'],
|
||||
Swift: ['迅速'],
|
||||
Rust: ['锈'],
|
||||
'Claude Code': ['Claude·科德'],
|
||||
'Git AI Author': ['Git AI 作者']
|
||||
},
|
||||
ja: {
|
||||
Codex: ['法典', 'コーデックス'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['クロード'],
|
||||
Grok: ['グロック'],
|
||||
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
||||
Cursor: ['カーソル'],
|
||||
OpenCode: ['オープンコード', 'オープン・コード'],
|
||||
OpenClaw: ['オープンクロー'],
|
||||
OpenClaude: ['オープンクロード'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['続ける', '続行'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['ガチョウ', '雁'],
|
||||
Pi: ['円周率'],
|
||||
Droid: ['ロボット', 'ドロイド'],
|
||||
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
||||
Discord: ['不和'],
|
||||
Linear: ['線形'],
|
||||
Agent: ['エージェント'],
|
||||
Agents: ['エージェント'],
|
||||
agent: ['エージェント'],
|
||||
agents: ['エージェント'],
|
||||
Commit: ['コミット'],
|
||||
Commits: ['コミット'],
|
||||
commit: ['コミット'],
|
||||
commits: ['コミット'],
|
||||
Markdown: ['マークダウン'],
|
||||
markdown: ['マークダウン'],
|
||||
Hermes: ['エルメス', 'ヘルメス'],
|
||||
PowerShell: ['パワーシェル'],
|
||||
Mermaid: ['マーメイド'],
|
||||
'Claude Code': ['Claude コード'],
|
||||
'VS Code': ['VS コード'],
|
||||
Repo: ['リポジトリ', 'リポ'],
|
||||
Repos: ['リポジトリ', 'リポ'],
|
||||
repo: ['リポジトリ', 'リポ'],
|
||||
repos: ['リポジトリ', 'リポ'],
|
||||
// 端子 is the zh mistranslation and never appears in ja; the ja one is 端末, which the phrase
|
||||
// fixes rewrite to ターミナル rather than back to Latin.
|
||||
Terminal: ['ターミナル'],
|
||||
Terminals: ['ターミナル'],
|
||||
terminal: ['ターミナル'],
|
||||
terminals: ['ターミナル']
|
||||
},
|
||||
es: {
|
||||
Codex: ['códice', 'Códice'],
|
||||
Gemini: ['Géminis'],
|
||||
Claude: ['claudia', 'Claudia'],
|
||||
Orca: ['orca', 'Orcas', 'orcas'],
|
||||
OpenCode: ['código abierto', 'Código abierto'],
|
||||
OpenClaude: ['Openclaude'],
|
||||
Antigravity: ['antigravedad', 'Antigravedad'],
|
||||
'GitHub Copilot': ['Copiloto de GitHub'],
|
||||
Discord: ['discordia'],
|
||||
Linear: ['lineal', 'Lineal'],
|
||||
Jira: ['jira'],
|
||||
Agent: ['Agente', 'agente'],
|
||||
Agents: ['Agentes', 'agentes'],
|
||||
agent: ['agente'],
|
||||
agents: ['agentes'],
|
||||
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
||||
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
||||
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
||||
commits: ['confirmaciones', 'compromisos'],
|
||||
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
||||
markdown: ['reducción', 'rebaja', 'rebajas'],
|
||||
Repo: ['Repositorio', 'repositorio'],
|
||||
Repos: ['Repositorios', 'repositorios'],
|
||||
repo: ['repositorio'],
|
||||
repos: ['repositorios']
|
||||
}
|
||||
}
|
||||
@@ -46,5 +46,16 @@ export const CJK_LATIN_SPACED_TERMS = [
|
||||
'PowerShell',
|
||||
'Git AI Author',
|
||||
'Token',
|
||||
'token'
|
||||
'token',
|
||||
'fast-forward',
|
||||
'Cookie',
|
||||
'Cookies',
|
||||
'upstream',
|
||||
'origin',
|
||||
'Hermes',
|
||||
'DuckDuckGo',
|
||||
'Mermaid',
|
||||
'VS Code',
|
||||
'IDE',
|
||||
'Git'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Japanese overrides for English values whose translation depends on the call site.
|
||||
export const JA_KEY_OVERRIDES = {
|
||||
// "on" is the toggle state here, but the preposition in the delete confirmation below.
|
||||
'auto.components.github.PRFilterSections.1e9b5244f2': { ja: 'オン' },
|
||||
'auto.components.settings.TerminalPane.29154326bb': { ja: 'オン' },
|
||||
'auto.components.automations.AutomationsPage.1b586f0e2b': { ja: 'の' },
|
||||
// Bare "Open" is the PR state elsewhere; these two are the action, as ko/zh/es render it.
|
||||
'auto.components.browser.pane.BrowserPane.756bfc25c9': { ja: '開く' },
|
||||
'auto.components.right.sidebar.checks.panel.content.7c1f0a2b11': { ja: '開く' }
|
||||
}
|
||||
@@ -1,9 +1,46 @@
|
||||
// Japanese phrase fixes from high-visibility UI audit rounds 1–4.
|
||||
// Why: keep locale-phrase-fixes.mjs under max-lines while preserving repair coverage.
|
||||
|
||||
const JA_SENTENCE_CHAR = '[-ヿ一-龯、。・「」()]'
|
||||
|
||||
// Two-word commands (`git commit`, `orca terminal`) must keep their second word in Latin.
|
||||
const COMMAND_HEAD = '(?<!\\b(?:git|gh|glab|orca|npm|pnpm|npx|yarn|docker|kubectl) )'
|
||||
|
||||
// #12113 stopped new damage; ~700 values still read "terminal を閉じる" and are healed here.
|
||||
// Anchored on adjacent Japanese, so `--agent`, agents.md and "Agent SDK" keep their spelling.
|
||||
const RELOCALIZED_GENERIC_TERMS = [
|
||||
['[Tt]erminals?', 'ターミナル', 'terminal'],
|
||||
['[Cc]ommits?', 'コミット', 'commit'],
|
||||
['[Rr]epos?', 'リポジトリ', 'repo'],
|
||||
// Agent stays Latin in ja; this only normalizes the case.
|
||||
['[Aa]gents?', 'Agent', 'agent'],
|
||||
['[Ww]orktrees?', 'ワークツリー', 'worktree']
|
||||
].flatMap(([latin, katakana, whenEnIncludes]) => [
|
||||
{
|
||||
// The trailing space only goes when Japanese follows, so 和欧間スペース survives before {{…}}.
|
||||
pattern: new RegExp(
|
||||
`(?<=${JA_SENTENCE_CHAR}) ?${COMMAND_HEAD}${latin}(?![-\\w.])(?! [A-Za-z])(?: (?=${JA_SENTENCE_CHAR}))?`,
|
||||
'g'
|
||||
),
|
||||
replacement: katakana,
|
||||
whenEnIncludes
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(
|
||||
`(?<![-\\w])${COMMAND_HEAD}${latin}(?![-\\w.]) ?(?=${JA_SENTENCE_CHAR})`,
|
||||
'g'
|
||||
),
|
||||
replacement: katakana,
|
||||
whenEnIncludes
|
||||
}
|
||||
])
|
||||
|
||||
export const JA_PHRASE_FIXES = [
|
||||
...RELOCALIZED_GENERIC_TERMS,
|
||||
{ pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /代理人/g, replacement: 'エージェント', whenEnIncludes: 'agent' },
|
||||
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{ pattern: /支店/g, replacement: 'ブランチ', whenEnIncludes: 'ranch' },
|
||||
{ pattern: /港(?!口)/g, replacement: 'ポート', whenEnIncludes: 'ort' },
|
||||
{ pattern: /会議/g, replacement: 'セッション', whenEnIncludes: 'session' },
|
||||
@@ -198,7 +235,7 @@ export const JA_PHRASE_FIXES = [
|
||||
{ pattern: /電話サイズ/g, replacement: 'スマートフォンサイズ', whenEnIncludes: 'phone' },
|
||||
{
|
||||
pattern: /コンピュータと電話/g,
|
||||
replacement: 'コンピューターとスマートフォン',
|
||||
replacement: 'コンピュータとスマートフォン',
|
||||
whenEnIncludes: 'phone'
|
||||
},
|
||||
{ pattern: /前の問題/g, replacement: 'フロントマター', whenEnIncludes: 'Front Matter' },
|
||||
@@ -235,11 +272,40 @@ export const JA_PHRASE_FIXES = [
|
||||
whenEnIncludes: 'More PR actions'
|
||||
},
|
||||
{ pattern: /アクション/g, replacement: '操作', whenEnIncludes: 'action' },
|
||||
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Select' },
|
||||
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Choose' },
|
||||
{ pattern: /入力してください/g, replacement: '入力', whenEnIncludes: 'Enter' },
|
||||
{ pattern: /追加してください/g, replacement: '追加', whenEnIncludes: 'Add' },
|
||||
{ pattern: /試してください/g, replacement: '試す', whenEnIncludes: 'Try' },
|
||||
// Orca's host covers SSH hosts and this computer; skipped where the English also says server.
|
||||
{
|
||||
pattern: /(ランタイム)?サーバー/g,
|
||||
replacement: 'ホスト',
|
||||
whenEnMatches: /^(?![\s\S]*\bservers?\b)[\s\S]*\bhosts?\b/i
|
||||
},
|
||||
{ pattern: /上流/g, replacement: 'upstream', whenEnIncludes: 'upstream' },
|
||||
{ pattern: /起源/g, replacement: 'origin', whenEnIncludes: 'origin' },
|
||||
{ pattern: /段階的な変更/g, replacement: 'ステージ済みの変更', whenEnIncludes: 'staged' },
|
||||
{ pattern: /紛争/g, replacement: '競合', whenEnIncludes: 'conflict' },
|
||||
{ pattern: /資格情報/g, replacement: '認証情報', whenEnIncludes: 'credential' },
|
||||
{ pattern: /未知/g, replacement: '不明', whenEnIncludes: 'unknown' },
|
||||
{ pattern: /クッキー/g, replacement: 'Cookie', whenEnIncludes: 'cookie' },
|
||||
{ pattern: /プロフィール/g, replacement: 'プロファイル', whenEnIncludes: 'profile' },
|
||||
{ pattern: /(?<![ンプリ])ロード中/g, replacement: '読み込み中', whenEnIncludes: 'load' },
|
||||
{
|
||||
pattern: /(?<![ンプリ])ロードしています/g,
|
||||
replacement: '読み込んでいます',
|
||||
whenEnIncludes: 'load'
|
||||
},
|
||||
{ pattern: /構成されて/g, replacement: '設定されて', whenEnIncludes: 'configur' },
|
||||
{ pattern: /第一方/g, replacement: 'ファーストパーティ', whenEnIncludes: 'first-party' },
|
||||
{ pattern: /早送り/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
|
||||
{ pattern: /ファストフォワード/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
|
||||
{ pattern: /([-ヿ一-龯])\.\.\./g, replacement: '$1…', whenEnMatches: /./ },
|
||||
{ pattern: /([゠-ヿ]) (?=[゠-ヿ])/g, replacement: '$1', whenEnMatches: /./ },
|
||||
{ pattern: /サーバ(?!ー)/g, replacement: 'サーバー', whenEnMatches: /./ },
|
||||
{ pattern: /フォルダ(?!ー)/g, replacement: 'フォルダー', whenEnMatches: /./ },
|
||||
{ pattern: /エディタ(?!ー)/g, replacement: 'エディター', whenEnMatches: /./ },
|
||||
{ pattern: /レンダラ(?!ー)/g, replacement: 'レンダラー', whenEnMatches: /./ },
|
||||
{ pattern: /ブラウザー/g, replacement: 'ブラウザ', whenEnMatches: /./ },
|
||||
{ pattern: /エミュレーター/g, replacement: 'エミュレータ', whenEnMatches: /./ },
|
||||
{ pattern: /コンピューター/g, replacement: 'コンピュータ', whenEnMatches: /./ },
|
||||
{ pattern: /インターフェイス/g, replacement: 'インターフェース', whenEnMatches: /./ },
|
||||
// Why: JP engineers use "Issue" in Latin, not katakana. Runs last so all *→イシュー fixes above normalize to Issue.
|
||||
{ pattern: /イシュー/g, replacement: 'Issue', whenEnIncludes: 'issue' }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// One rendering per English string. 215 sources carried two or more Japanese forms; picks follow
|
||||
// the catalog majority — action labels drop する, completion notices use 〜しました, status labels
|
||||
// are 体言, and デフォルト / フィルター / スコープ / ディストリビューション / 並べ替え win their pairs.
|
||||
export const JA_UNIFIED_VALUES = {
|
||||
"Can't reach {{value0}}": '{{value0}} に接続できません',
|
||||
'Copy Address': 'アドレスをコピー',
|
||||
'Open Externally': '外部で開く',
|
||||
'Show Orca Mobile Button': 'Orca Mobile ボタンを表示',
|
||||
Paste: '貼り付け',
|
||||
'WSL default': 'WSL のデフォルト',
|
||||
'Unable to open the workspace attached to this issue.':
|
||||
'この Issue にリンクされたワークスペースを開けませんでした。',
|
||||
'Failed to connect to project.': 'プロジェクトへの接続に失敗しました。',
|
||||
'Dev-only tools for exercising UI states.': 'UI の状態を確認するための開発者専用ツール。',
|
||||
'Start new workspace': '新規ワークスペースを開始',
|
||||
'Open workspace attached to issue': 'Issue にリンクされたワークスペースを開く',
|
||||
'No one assigned': '担当者なし',
|
||||
'Edit assignees': '担当者を編集',
|
||||
'Edit labels on GitHub': 'GitHub でラベルを編集',
|
||||
'check #': 'チェック #',
|
||||
Started: '開始済み',
|
||||
'Status:': 'ステータス:',
|
||||
'Loading check details…': 'チェックの詳細を読み込んでいます…',
|
||||
'No description provided.': '説明はありません。',
|
||||
'Review comment added.': 'レビューコメントを追加しました。',
|
||||
Copied: 'コピーしました',
|
||||
'No activity yet.': 'まだアクティビティはありません。',
|
||||
Merge: 'マージ',
|
||||
'Create workspace': 'ワークスペースを作成',
|
||||
'Loading labels': 'ラベルを読み込んでいます',
|
||||
Hide: '非表示',
|
||||
Resolve: '解決',
|
||||
'Start workspace': 'ワークスペースを開始',
|
||||
Unassigned: '未割り当て',
|
||||
'Copy prompt': 'プロンプトをコピー',
|
||||
'Failed to copy {{value0}}': '{{value0}} のコピーに失敗しました',
|
||||
'{{value0}} copied': '{{value0}} をコピーしました',
|
||||
'Add project': 'プロジェクトを追加',
|
||||
'Cloning...': 'クローン作成中…',
|
||||
'Add SSH host': 'SSH ホストを追加',
|
||||
Later: '後で',
|
||||
"Don't ask again": '今後表示しない',
|
||||
'Atlassian account settings': 'Atlassian のアカウント設定',
|
||||
'Create a token in': 'でトークンを作成します',
|
||||
'Atlassian API token': 'Atlassian API トークン',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.':
|
||||
'Jira Cloud のサイト URL、Atlassian のメールアドレス、API トークンを使って Issue を閲覧します。',
|
||||
'Connect Jira site': 'Jira サイトに接続',
|
||||
Updated: '更新済み',
|
||||
Owner: 'オーナー',
|
||||
'Connect Jira': 'Jira に接続',
|
||||
'Change the query or clear it.': '検索条件を変更するか、クリアしてください。',
|
||||
'No matching GitHub work': '一致する GitHub の作業はありません',
|
||||
'No GitLab work matches this filter.': 'このフィルターに一致する GitLab の作業はありません。',
|
||||
'No GitLab MRs match this filter.': 'このフィルターに一致する GitLab の MR はありません。',
|
||||
'No GitLab issues match this filter.': 'このフィルターに一致する GitLab の Issue はありません。',
|
||||
Resume: '再開',
|
||||
github: 'GitHub',
|
||||
'Could not build launch command for {{value0}}.':
|
||||
'{{value0}} の起動コマンドを作成できませんでした。',
|
||||
Update: '更新',
|
||||
'Re-check': '再確認',
|
||||
'Checks passed': 'チェックに合格しました',
|
||||
'Copy command': 'コマンドをコピー',
|
||||
Pinned: 'ピン留め済み',
|
||||
'Remove {{value0}} filter': '{{value0}} フィルターを解除',
|
||||
selected: '選択中',
|
||||
'Remove {{value0}}': '{{value0}} を削除',
|
||||
'Last active': '最終アクティブ',
|
||||
'Unpushed commits': '未プッシュのコミット',
|
||||
Ignored: '無視済み',
|
||||
Sort: '並べ替え',
|
||||
Context: 'コンテキスト',
|
||||
'Delete {{value0}}': '{{value0}} を削除',
|
||||
Commits: 'コミット',
|
||||
'Expand details': '詳細を展開',
|
||||
'Uncommitted changes': '未コミットの変更',
|
||||
'No projects match your search.': '検索条件に一致するプロジェクトはありません。',
|
||||
Scope: 'スコープ',
|
||||
'Context copied': 'コンテキストをコピーしました',
|
||||
'Worktree not ready — try again in a moment.':
|
||||
'ワークツリーが準備できていません — しばらくしてから再試行してください。',
|
||||
'Open a workspace before resuming a session.':
|
||||
'セッションを再開する前に、ワークスペースを開いてください。',
|
||||
Run: '実行',
|
||||
'ios simulator': 'iOS シミュレータ',
|
||||
'Kill session {{value0}}': 'セッション {{value0}} を強制終了',
|
||||
Disconnected: '切断済み',
|
||||
'OpenCode Go Usage': 'OpenCode Go の使用量',
|
||||
'Switch to': '切り替え先',
|
||||
'Update failed': '更新に失敗しました',
|
||||
cancelling: 'キャンセル中',
|
||||
Unavailable: '利用不可',
|
||||
'Usage unavailable': '使用量を取得できません',
|
||||
Usage: '使用量',
|
||||
Bundled: '同梱',
|
||||
'Update available': '更新があります',
|
||||
Skipped: 'スキップ済み',
|
||||
'1 update available': '1 件の更新があります',
|
||||
'{{value0}} updates available': '{{value0}} 件の更新があります',
|
||||
Stop: '停止',
|
||||
'Up to date': '最新',
|
||||
'Host path': 'ホストパス',
|
||||
'Open project on SSH host': 'SSH ホスト上のプロジェクトを開く',
|
||||
'Clone from URL': 'URL からクローン',
|
||||
more: '件以上',
|
||||
'Keep hidden': '非表示のままにする',
|
||||
Manual: '手動',
|
||||
Automation: 'オートメーション',
|
||||
Notes: 'ノート',
|
||||
'Board settings': 'ボード設定',
|
||||
'Project on SSH host': 'SSH ホスト上のプロジェクト',
|
||||
'Created by automation': 'オートメーションによって作成',
|
||||
'Open in Orca': 'Orca で開く',
|
||||
'Failed to copy link': 'リンクのコピーに失敗しました',
|
||||
'Delete Worktree': 'ワークツリーを削除',
|
||||
rename: '名前変更',
|
||||
'Create workspace for {{value0}}': '{{value0}} のワークスペースを作成',
|
||||
'Browse folder': 'フォルダーを参照',
|
||||
'{{value0}} failed': '{{value0}} 件失敗',
|
||||
Neutral: '中立',
|
||||
'Set up': 'セットアップ',
|
||||
'Add a persistent machine you can log into over SSH.':
|
||||
'SSH でログインできる永続的なマシンを追加します。',
|
||||
'on the server and paste the printed pairing URL.':
|
||||
'をサーバーで実行し、出力されたペアリング URL を貼り付けます。',
|
||||
'Keep Local Main Up to Date': 'ローカル main を最新の状態に保つ',
|
||||
Saved: '保存済み',
|
||||
Replace: '置換',
|
||||
'Account scope': 'アカウントスコープ',
|
||||
'Agent Permissions': 'Agent の権限',
|
||||
'Show the Orca Mobile button at the top of the left sidebar.':
|
||||
'左サイドバー上部に Orca Mobile ボタンを表示します。',
|
||||
'Enable Orca CLI': 'Orca CLI を有効化',
|
||||
'Registered the Orca CLI in PATH.': 'Orca CLI を PATH に登録しました。',
|
||||
'Failed to load CLI status.': 'CLI ステータスの読み込みに失敗しました。',
|
||||
'Fix PATH': 'PATH を修正',
|
||||
installed: 'インストール済み',
|
||||
cli: 'CLI',
|
||||
'Opened macOS Privacy & Security': 'macOS のプライバシーとセキュリティを開きました',
|
||||
'Could not request permission': '権限をリクエストできませんでした',
|
||||
Shown: '表示中',
|
||||
'Clear all': 'すべてクリア',
|
||||
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.':
|
||||
'接続された各 Linear ワークスペースには、アクティブなランタイムが保存したキーが 1 つあります。フルアクセスキーはキー所有者がアクセスできるすべてのチームを対象にできます。制限付きキーはいつでも置き換えられます。',
|
||||
'Add workspace access': 'ワークスペースへのアクセスを追加',
|
||||
'Learn more': '詳しく見る',
|
||||
'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Gitea の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Azure DevOps の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Bitbucket の認証情報は設定されていますが、認証できませんでした。トークンとリポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'The GitLab CLI is installed but not authenticated. Run this command in a terminal:':
|
||||
'GitLab CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
|
||||
'Install GitLab CLI': 'GitLab CLI をインストール',
|
||||
'CLI.': 'CLI。',
|
||||
'The GitHub CLI is installed but not authenticated. Run this command in a terminal:':
|
||||
'GitHub CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
|
||||
'Install GitHub CLI': 'GitHub CLI をインストール',
|
||||
'Disconnect {{value0}}': '{{value0}} の接続を解除',
|
||||
'Add Linear access to browse and link issues.':
|
||||
'Linear アクセスを追加して、Issue を閲覧およびリンクします。',
|
||||
'Pull requests and build statuses via Azure DevOps REST API tokens.':
|
||||
'Azure DevOps REST API トークン経由の PR とビルドステータス。',
|
||||
'Pull requests and build statuses via Bitbucket Cloud API tokens.':
|
||||
'Bitbucket Cloud API トークン経由の PR とビルドステータス。',
|
||||
'Reload from Disk': 'ディスクから再読み込み',
|
||||
'Refresh network interfaces': 'ネットワークインターフェースを更新',
|
||||
'CLI arguments': 'CLI 引数',
|
||||
Created: '作成日時',
|
||||
Switch: '切り替え',
|
||||
'Status unavailable': 'ステータスを取得できません',
|
||||
'Checking…': '確認中…',
|
||||
'Updating servers…': 'サーバーを更新しています…',
|
||||
'{{value0}} shortcut': '{{value0}} のショートカット',
|
||||
'Connect {{provider}}': '{{provider}} に接続',
|
||||
'Try again': '再試行',
|
||||
'Windows default': 'Windows のデフォルト',
|
||||
'Loading distributions': 'ディストリビューションを読み込んでいます',
|
||||
'WSL Distribution': 'WSL ディストリビューション',
|
||||
'Default Shell': 'デフォルトのシェル',
|
||||
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.':
|
||||
'右クリックでクリップボードを貼り付けます。Ctrl+右クリックでコンテキストメニューを開きます。',
|
||||
credentials: '認証情報',
|
||||
display: 'ディスプレイ',
|
||||
setup: 'セットアップ',
|
||||
note: 'メモ',
|
||||
update: '更新',
|
||||
'default branch': 'デフォルトブランチ',
|
||||
target: '対象',
|
||||
'Client default': 'クライアントのデフォルト',
|
||||
'Select distro': 'ディストリビューションを選択',
|
||||
'Sign out': 'サインアウト',
|
||||
'Available on': 'で利用可能',
|
||||
'Update this server manually once to enable remote updates.':
|
||||
'リモート更新を有効にするには、このサーバーを一度手動で更新してください。',
|
||||
Official: '公式',
|
||||
'Open on {{value0}}': '{{value0}} で開く',
|
||||
'Create {{value0}}': '{{value0}} を作成',
|
||||
'{{value0}} #{{value1}} is already open': '{{value0}} #{{value1}} はすでに開いています',
|
||||
'Copy Details': '詳細をコピー',
|
||||
'Clear Notes': 'ノートをクリア',
|
||||
'Fix with AI': 'AI で修正',
|
||||
'AI Fix': 'AI 修正',
|
||||
'Committed on Branch': 'コミット先のブランチ',
|
||||
Changes: '変更点',
|
||||
'Filter files by name': 'ファイル名でフィルター',
|
||||
'Choose the agent and edit the full command input before launch.':
|
||||
'起動前に Agent を選択し、コマンド入力全体を編集してください。',
|
||||
'Unable to resolve the workspace connection.': 'ワークスペースの接続を解決できません。',
|
||||
'Branch has unpushed commits': 'ブランチに未プッシュのコミットがあります',
|
||||
'No enabled AI agents. Configure agents in Settings.':
|
||||
'有効な AI Agent がありません。[設定] で Agent を構成してください。',
|
||||
'Saved AI agent is unavailable. Use Customize launch to choose another agent.':
|
||||
'保存された AI Agent は使用できません。[起動のカスタマイズ] から別の Agent を選択してください。',
|
||||
'Commit failure prompt is empty. Update Source Control AI settings.':
|
||||
'コミット失敗プロンプトが空です。ソース管理の AI 設定を更新してください。',
|
||||
'Create PR': 'PR を作成',
|
||||
'{{value0}} msgs': '{{value0}} 件のメッセージ',
|
||||
'No projects/folders match your search.': '検索に一致するプロジェクト/フォルダーはありません。',
|
||||
Keep: '保持',
|
||||
'Skill command copied and inserted below for review.':
|
||||
'スキルコマンドをコピーし、確認用に以下へ挿入しました。',
|
||||
'Try it out': '試してみる',
|
||||
'1 pending': '保留中 1 件',
|
||||
'Zoom in': 'ズームイン',
|
||||
'Zoom out': 'ズームアウト',
|
||||
Undo: '元に戻す',
|
||||
'{{value0}} failing': '{{value0}} 件失敗',
|
||||
'{{value0}} pending': '{{value0}} 件保留中',
|
||||
'Draft review': '下書きレビュー',
|
||||
'Switch profile': 'プロファイルを切り替え',
|
||||
'Draw on the page, then copy the markup to paste into your agent.':
|
||||
'ページに描き込み、マークアップをコピーして Agent に貼り付けます。',
|
||||
// Grab mode picks a page element and hands it to the AI; 掴む / グラブモード were literal.
|
||||
Grab: '取得',
|
||||
'Grab mode is unavailable in the web client.':
|
||||
'要素の取得モードは Web クライアントでは使用できません。',
|
||||
agent: 'Agent',
|
||||
'{{value0}} workspace{{value1}} connected':
|
||||
'{{value0}} ワークスペース{{value1}} が接続されました',
|
||||
Plugins: 'プラグイン',
|
||||
'Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}':
|
||||
'Orca は引き続き使用できますが、このセッションでは orca status や orca terminal などのコマンドとオーケストレーションを利用できません。\n\n{{guidance}}\n\n原因: {{cause}}'
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import { JA_UNIFIED_VALUES } from './locale-ja-unified-values.mjs'
|
||||
|
||||
// Japanese value overrides from high-visibility UI audit rounds 1–4.
|
||||
// Why: keep locale-value-overrides.mjs under max-lines while preserving exact-match repairs.
|
||||
export const JA_VALUE_OVERRIDES = {
|
||||
const JA_AUDIT_OVERRIDES = {
|
||||
'Retry loading presets': 'プリセットの読み込みを再試行',
|
||||
'Retry Download': 'ダウンロードを再試行',
|
||||
'Enter a reviewer': 'レビュアーを入力',
|
||||
'Choose folder': 'フォルダーを選択',
|
||||
'Choose pet': 'ペットを選択',
|
||||
'Choose a sound': 'サウンドを選択',
|
||||
'Pick your default agent': 'デフォルトのエージェントを選択',
|
||||
'Pick your default agent': 'デフォルトの Agent を選択',
|
||||
'Complete {{artifact_url}}': '{{artifact_url}} を完了',
|
||||
'Control terminals and agents from your phone.':
|
||||
'スマートフォンからターミナルとエージェントを操作',
|
||||
'スマートフォンからターミナルと Agent を操作します。',
|
||||
'Add reviewer': 'レビュアーを追加',
|
||||
Reviewer: 'レビュアー',
|
||||
Reviewers: 'レビュアー',
|
||||
'Fix broken checks': '失敗したチェックを修正',
|
||||
'No broken checks to fix.': '修正が必要な失敗したチェックはありません',
|
||||
'No broken checks to fix.': '修正が必要なチェックはありません。',
|
||||
'Started an AI agent for the broken checks.':
|
||||
'失敗したチェックに対して AI エージェントを開始しました。',
|
||||
'失敗したチェックに対して AI Agent を開始しました。',
|
||||
'Failed to start an AI agent for the broken checks: {{value0}}':
|
||||
'失敗したチェックのため AI エージェントを開始できませんでした: {{value0}}',
|
||||
'失敗したチェックのため AI Agent を開始できませんでした: {{value0}}',
|
||||
'Checks unavailable': 'チェックは利用できません',
|
||||
'Checks pending': '保留中のチェック',
|
||||
checks: 'チェック',
|
||||
@@ -92,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
Launch: '起動',
|
||||
'Launch:': '起動:',
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch agent': 'Agent を起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
@@ -148,8 +150,8 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Select a workspace to browse files': 'ファイルを参照するワークスペースを選択',
|
||||
'Select a workspace to search': '検索するワークスペースを選択',
|
||||
'Select a workspace to view changes': '変更を表示するワークスペースを選択',
|
||||
'Choose agent to fix commit failure': 'コミット失敗を修正するエージェントを選択',
|
||||
'Choose an agent for this commit failure': 'このコミット失敗に対するエージェントを選択',
|
||||
'Choose agent to fix commit failure': 'コミット失敗を修正する Agent を選択',
|
||||
'Choose an agent for this commit failure': 'このコミット失敗に対する Agent を選択',
|
||||
'Enter a commit message to commit': 'コミットメッセージを入力してコミット',
|
||||
'Choose parent folder...': '親フォルダーを選択...',
|
||||
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
|
||||
@@ -234,5 +236,313 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Cursor Opacity': 'カーソルの不透明度',
|
||||
'Cursor Shape': 'カーソル形状',
|
||||
'Blinking Cursor': 'カーソルの点滅',
|
||||
'Terminal Cursor': 'Terminal カーソル'
|
||||
'Terminal Cursor': 'ターミナルのカーソル',
|
||||
'Local folders and Git repositories': 'ローカルフォルダーと Git リポジトリ',
|
||||
'Started an AI agent for the conflicts.': '競合の解決のために AI Agent を開始しました。',
|
||||
'Previous conflict': '前の競合',
|
||||
'1 commit ahead of {{value0}}': '{{value0}} より 1 コミット進んでいます',
|
||||
'1 commit behind {{value0}}': '{{value0}} より 1 コミット遅れています',
|
||||
'3 commits ahead': '3 コミット進んでいます',
|
||||
'Fast-forwarded {{branch}} by 1 commit.': '{{branch}} を 1 コミット分 fast-forward しました。',
|
||||
'e.g. cloudflared access ssh --hostname %h': '例: cloudflared access ssh --hostname %h',
|
||||
'Git is not installed': 'Git がインストールされていません',
|
||||
'CLI not installed': 'CLI がインストールされていません',
|
||||
'Locate SDK folder': 'SDK フォルダーを選択',
|
||||
'Not found. Install Android Studio, then create a Virtual Device.':
|
||||
'見つかりません。Android Studio をインストールしてから、仮想デバイスを作成してください。',
|
||||
'ANSI blue color': 'ANSI青色',
|
||||
'MR !{{value0}}': 'MR !{{value0}}',
|
||||
mrs: 'MR',
|
||||
'No human comments.': 'ユーザーによるコメントはありません。',
|
||||
'Any state': 'すべての状態',
|
||||
State: '状態',
|
||||
Health: '健全性',
|
||||
Ordering: '並べ替え',
|
||||
Start: '開始',
|
||||
'Local {{value0}} is behind {{value1}}': 'ローカル {{value0}} は {{value1}} より遅れています',
|
||||
'behind main': 'main より遅れています',
|
||||
'behind (base commit:': '遅れ (ベースコミット:',
|
||||
'No pending todos. You’re all caught up!': '保留中の ToDo はありません。すべて完了しています!',
|
||||
High: '高',
|
||||
Medium: '中',
|
||||
Low: '低',
|
||||
'Split Terminal Down': 'ターミナルを下に分割',
|
||||
'Split Terminal Right': 'ターミナルを右に分割',
|
||||
'Split Up': '上に分割',
|
||||
'Split Down': '下に分割',
|
||||
"Install the Browser Use skill so agents can operate Orca's browser.":
|
||||
'Browser Use スキルをインストールすると、 Agent が Orca のブラウザを操作できるようになります。',
|
||||
'bold ·': '太字 ·',
|
||||
'Duplicate of another issue in this repository': 'このリポジトリ内の別の Issue と重複',
|
||||
'Choose a different issue.': '別の Issue を選択してください。',
|
||||
'No matching issues loaded.': '一致する Issue がありません。',
|
||||
'Duplicate of another issue': '別の Issue と重複',
|
||||
'mentioned this': 'がこの Issue をメンションしました',
|
||||
'closed this': 'がこの Issue をクローズしました',
|
||||
'reopened this': 'がこの Issue を再オープンしました',
|
||||
'moved this': 'がこの Issue を移動しました',
|
||||
'Failed to save setup startup behavior.': 'セットアップ時の起動設定を保存できませんでした。',
|
||||
'Failed to resolve MR base.': 'MR のベースブランチを特定できませんでした。',
|
||||
'Failed to copy context.': 'コンテキストのコピーに失敗しました。',
|
||||
// 3-way split: [this][query]["]. The closing side is already 」, so end the sentence here.
|
||||
'No settings found for "': '検索条件に一致する設定が見つかりませんでした:「',
|
||||
"Doesn't apply to agents where you've overridden launch arguments.":
|
||||
'起動引数を上書きしている Agent には適用されません。',
|
||||
'Check for Server Updates': 'サーバーの更新を確認',
|
||||
'All servers are up to date.': 'すべてのサーバーが最新です。',
|
||||
'Open worktree': 'ワークツリーを開く',
|
||||
'Update this server': 'このサーバーを更新',
|
||||
'Git repository': 'Git リポジトリ',
|
||||
'{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.':
|
||||
'{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。',
|
||||
'The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists.':
|
||||
'アプリシェルのレンダリングを完了できませんでした。再マウントを試すか、エラーが解決しない場合は Orca を再起動してください。',
|
||||
'Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.':
|
||||
'モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。',
|
||||
'Change this later in Settings → Browser.': '後で [設定] → [ブラウザ] から変更できます。',
|
||||
'Orca Cloud sign-in is not configured': 'Orca Cloud へのサインインが設定されていません',
|
||||
'{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry.':
|
||||
'{{provider}} はこの環境で利用できる認証情報で認証できませんでした。{{provider}} のログインまたは環境トークンを確認して、再試行してください。',
|
||||
'e.g. ollama run llama3.1 {prompt}': '例: ollama run llama3.1 {prompt}',
|
||||
'e.g. ollama run llama3.1 {{value0}}': '例: ollama run llama3.1 {{value0}}',
|
||||
'Connecting terminal...': 'ターミナルに接続中…',
|
||||
'Starring…': 'スターを付けています…',
|
||||
'self-hosted': 'セルフホスト',
|
||||
'not-authenticated': '未認証',
|
||||
'side-by-side': '左右に並べて',
|
||||
'Side-by-side': '左右に並べて',
|
||||
'Pull requests and commit statuses via the Gitea REST API.':
|
||||
'Gitea REST API 経由の PR とコミットステータス。',
|
||||
Key: 'キー',
|
||||
slug: 'スラッグ',
|
||||
// Rendered as [fragment][var name][fragment] by two cards that share these fragments.
|
||||
'Public repositories are detected from their git remote. Set':
|
||||
'パブリックリポジトリは git リモートから検出されます。設定:',
|
||||
'for private repositories, and set': '(プライベートリポジトリ用)。また、',
|
||||
'only when Orca cannot derive the API base URL from the git remote.':
|
||||
'は、Orca が git リモートから API のベース URL を取得できない場合のみ設定します。',
|
||||
'only when Orca cannot derive the API URL from the remote.':
|
||||
'は、Orca がリモートから API URL を取得できない場合のみ設定します。',
|
||||
Set: '設定:',
|
||||
'. Set': '。設定:',
|
||||
', or set': '、または',
|
||||
and: 'と',
|
||||
// Concatenated: the leading space is part of the string, not padding.
|
||||
' and ': ' と ',
|
||||
' in {{value0}}': '({{value0}})',
|
||||
' • Last scan error: {{value0}}': ' • 最終スキャンエラー: {{value0}}',
|
||||
' vs {{value0}}': ' vs {{value0}}',
|
||||
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as':
|
||||
'ワークスペースを作成すると、Orca はリモートベースを更新し、一致するローカルブランチを安全に fast-forward します。対象は',
|
||||
'. This keeps commands like': '。これにより、',
|
||||
'from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.':
|
||||
'などのコマンドが古い履歴と比較されるのを防ぎます。対象のブランチにコミットしていない変更やローカルのみのコミットがある場合、Orca は更新をスキップします。',
|
||||
'Supports skills, file paths, and built-in commands like':
|
||||
'スキル、ファイルパス、および次のような組み込みコマンドに対応しています:',
|
||||
' · {{value0}} external': ' · 外部 {{value0}} 件',
|
||||
'Use your current': '現在のアカウントを使用',
|
||||
Running: '実行中',
|
||||
'Back to runs': '実行一覧に戻る',
|
||||
Failed: '失敗',
|
||||
Unknown: '不明',
|
||||
'Identity file': '秘密鍵ファイル',
|
||||
'Identity File': '秘密鍵ファイル',
|
||||
key: 'キー',
|
||||
failing: '失敗',
|
||||
'Selected host': '選択したホスト',
|
||||
'Copied cleanup payload.': 'クリーンアップ用ペイロードをコピーしました。',
|
||||
'Jump to...': '移動先…',
|
||||
'Start work': '作業を開始',
|
||||
'Remove {{value0}} contained {{value1}} from Orca':
|
||||
'含まれている {{value1}} {{value0}} 件を Orca から削除',
|
||||
'People in {{orgName}} who can collaborate on Orca.':
|
||||
'{{orgName}} で Orca を共同利用できるメンバーです。',
|
||||
'People in your organization who can collaborate on Orca.':
|
||||
'組織内で Orca を共同利用できるメンバーです。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'ソース管理のコミット、PR、ブランチ名、修正操作に使うアクションレシピを追加します。',
|
||||
'Repo fallback for text actions that select Custom command.':
|
||||
'「カスタムコマンド」を選択したテキスト操作に使う、リポジトリ単位のフォールバック。',
|
||||
'Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.':
|
||||
'レシピで作成されたランタイムはワークスペースに紐づいています。クラッシュ、作成失敗、手動リカバリの後に残った古いエントリをクリーンアップします。',
|
||||
'Failed to register the recipe-created project root on the runtime.':
|
||||
'レシピによって作成されたプロジェクトルートをランタイムに登録できませんでした。',
|
||||
'Update all {{value0}} servers': '{{value0}} 台のサーバーをすべて更新',
|
||||
'This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link.':
|
||||
'この QR コードでは、モバイル向けの制限付きアクセスのみ利用できます。完全版の Web アプリを使用するには、[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] → [新規リンク] から、ブラウザ用アクセスリンクを開いてください。',
|
||||
'Loading skills...': 'スキルを読み込み中…',
|
||||
'Start agents on your tasks without leaving Orca': 'Orca から離れずに、タスクから Agent を開始',
|
||||
'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}':
|
||||
'ワークスペースは {{value0}} から作成されましたが、{{value2}} のため Orca はローカル {{value1}} を fast-forward できませんでした',
|
||||
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.':
|
||||
'このワークスペースでは、ターミナル、ブラウザ、またはエディターのレンダリングに失敗しました。再マウントを試してください。',
|
||||
'The dashboard could not finish rendering. Retry to remount it, or reopen it.':
|
||||
'ダッシュボードのレンダリングを完了できませんでした。再マウントを試すか、開き直してください。',
|
||||
|
||||
'Review the prompt before starting an agent.':
|
||||
'Agent を開始する前に、プロンプトを確認してください。',
|
||||
'Use Add Project to enter a path on the selected host.':
|
||||
'[プロジェクトの追加] から、選択したホスト上のパスを入力します。',
|
||||
'Use repo-relative paths like packages/web or apps/api.':
|
||||
'packages/web や apps/api などのリポジトリ相対パスを使用します。',
|
||||
'orca.yaml first, then your local commands.': 'まず orca.yaml、次にローカルのコマンドの順です。',
|
||||
'Rename Orca-created branches from the initial agent task.':
|
||||
'Orca が作成したブランチの名前を、最初の Agent タスクに基づいて変更します。',
|
||||
'Dim files matched by .gitignore in the file explorer.':
|
||||
'ファイルエクスプローラーで、.gitignore に一致するファイルを薄く表示します。',
|
||||
'Command line Orca runs when a text recipe uses Custom command.':
|
||||
'テキストレシピで「カスタムコマンド」を使用したときに Orca が実行するコマンドライン。',
|
||||
'Let programs in the terminal copy to the system clipboard through OSC 52, including over SSH.':
|
||||
'ターミナル内のプログラムが、OSC 52 を使ってシステムのクリップボードにコピーできるようにします(SSH 経由を含む)。',
|
||||
'Choose which optional saved Codex account powers live quota reads.':
|
||||
'ライブクォータの取得に使用する、保存済みの Codex アカウント(任意)を選択します。',
|
||||
'This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.':
|
||||
'このプラグインにはワーカープロセスがありません。それでも、ユーザーや Agent が使用したときに、記載された手順が操作を引き起こす可能性があります。有効にする前に、以下の手順とコマンドを確認してください。',
|
||||
'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.':
|
||||
'保存済みの SSH ホストとその認証情報を、このコンピュータから削除します。リモートのファイルは削除されません。',
|
||||
'Your new worktree is current, but local {{value0}} is {{value1}} {{value2}} behind. AI diffs may miss recent commits.':
|
||||
'新規ワークツリーは最新ですが、ローカル {{value0}} は {{value1}} {{value2}} 遅れています。AI の差分に最近のコミットが反映されない可能性があります。',
|
||||
'This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.':
|
||||
'マージのブロックが解除されるまでに、このチェックには GitHub 上での手動操作(例: ワークフロー実行の承認)が必要です。',
|
||||
'This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.':
|
||||
'未保存の編集がある状態で、このファイルがディスク上で変更されました。保存すると、ディスク上の最新の内容を上書きします。',
|
||||
'Local terminal reveal is unavailable while a remote runtime is active':
|
||||
'リモートランタイムがアクティブな間は、ローカルターミナルの表示は利用できません',
|
||||
'No quick commands saved.': '保存されているクイックコマンドはありません。',
|
||||
'Repository not in Orca': 'このリポジトリは Orca に登録されていません',
|
||||
'host folder not selected': 'ホストフォルダーが選択されていません',
|
||||
'Add a Git repository or folder that already exists on the selected host.':
|
||||
'選択したホストに既に存在する Git リポジトリまたはフォルダーを追加します。',
|
||||
'Add another project from the selected host.': '選択したホストから別のプロジェクトを追加します。',
|
||||
'Enter a host path for the clone destination.': 'クローン先のホスト上のパスを入力します。',
|
||||
'Use a host path to add projects from a remote host.':
|
||||
'ホスト上のパスを指定して、リモートホストからプロジェクトを追加します。',
|
||||
'Use Add Project to enter a host path.':
|
||||
'[プロジェクトの追加] から、ホスト上のパスを入力します。',
|
||||
'Opens as a project on this host · {{value0}}':
|
||||
'このホスト上のプロジェクトとして開きます · {{value0}}',
|
||||
'No project path is available on this host for attachments.':
|
||||
'このホスト上に、添付に使用できるプロジェクトパスがありません。',
|
||||
'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.':
|
||||
'Orca からのみ削除します。リモートのファイル、ワークツリー、ブランチはそのまま残ります。',
|
||||
'Agent location': 'Agent の実行場所',
|
||||
upstream: 'upstream',
|
||||
Upstream: 'upstream',
|
||||
'Commit staged changes': 'ステージ済みの変更をコミット',
|
||||
'Generate the commit message from staged changes.':
|
||||
'ステージ済みの変更からコミットメッセージを生成します。',
|
||||
lan: 'LAN',
|
||||
deploy: 'deploy',
|
||||
'Connect this source to check for Hermes automations in the remote profile.':
|
||||
'このソースに接続すると、リモートプロファイル内の Hermes オートメーションを確認できます。',
|
||||
'Notes sent to active agent.': 'メモをアクティブな Agent に送信しました。',
|
||||
'Review before attaching. Captured page context may include visible site content.':
|
||||
'添付する前に確認してください。取得したページのコンテキストには、表示中のサイトの内容が含まれる場合があります。',
|
||||
'Agent took too long to start. The workspace is ready — paste the {{value0}} when the agent is idle.':
|
||||
'Agent の起動に時間がかかりすぎました。ワークスペースの準備はできています。 Agent がアイドル状態になったら {{value0}} を貼り付けてください。',
|
||||
'Install the Orca CLI before running agent skill setup.':
|
||||
'Agent スキルのセットアップを実行する前に、Orca CLI をインストールしてください。',
|
||||
'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.':
|
||||
'Orca を実行しているコンピュータのターミナルで grok を実行し、起動するまで待ってください。サインインを求められた場合は完了してから、使用状況を再取得してください。チャットメッセージを送る必要はありません。',
|
||||
'Choose an enabled agent before saving.': '保存する前に、有効な Agent を選択してください。',
|
||||
'Selected agent is disabled. Choose an enabled agent before creating.':
|
||||
'選択した Agent は無効です。作成する前に、有効な Agent を選択してください。',
|
||||
'Choose an agent before starting.': '開始する前に、 Agent を選択してください。',
|
||||
'Change this later from the project menu.': '後でプロジェクトメニューから変更できます。',
|
||||
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time.":
|
||||
'Orca はすべての CLI Agent で動作します。最もよく使うものを選択してください。いつでも切り替えられます。',
|
||||
'Downloading paused': 'ダウンロード一時停止中',
|
||||
Canceled: 'キャンセル済み',
|
||||
'Thinking...': '思考中…',
|
||||
'No attached worktrees yet': '紐づくワークツリーはまだありません',
|
||||
'Started an AI agent for the commit failure.':
|
||||
'コミット失敗を修正するために AI Agent を開始しました。',
|
||||
'terminals until reset': 'リセットするまでターミナルを維持',
|
||||
'Failed to import repo icon': 'リポジトリアイコンのインポートに失敗しました',
|
||||
'Terminal Panes': 'ターミナルペイン',
|
||||
'Option composes special characters for your keyboard layout.':
|
||||
'Option キーで、キーボードレイアウトに応じた特殊文字を入力します。',
|
||||
'There are local terminals with running processes. Close the window anyway?':
|
||||
'プロセスが実行中のローカルターミナルがあります。このままウィンドウを閉じますか?',
|
||||
'Auto-generates a new name when you leave this text box empty.':
|
||||
'このテキストボックスを空のままにすると、名前が自動生成されます。',
|
||||
'Auto-name workspace from first agent message':
|
||||
'最初の Agent メッセージからワークスペース名を自動生成',
|
||||
'Controls the split divider line between panes in light mode.':
|
||||
'ライトモードでのペイン間の分割線を制御します。',
|
||||
'Controls the split divider line between panes in dark mode.':
|
||||
'ダークモードでのペイン間の分割線を制御します。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
'プロンプトキャッシュが期限切れになるまでの時間を示すカウントダウンタイマー(Claude Agent )。',
|
||||
'Install and manage experimental Orca plugins.':
|
||||
'実験的機能の Orca プラグインをインストール・管理します。',
|
||||
"Resident memory held by Orca plus the processes under each worktree's terminals.":
|
||||
'Orca が保持する常駐メモリと、各ワークツリーのターミナル配下で実行中のプロセス。',
|
||||
'No local Claude, Codex, or OpenCode usage found yet. The overview will populate after the next agent session writes token logs.':
|
||||
'ローカルの Claude、Codex、OpenCode の使用状況はまだ見つかりません。次の Agent セッションがトークンログを書き込むと、概要に反映されます。',
|
||||
'Choose or add a project before creating a workspace.':
|
||||
'ワークスペースを作成する前に、プロジェクトを選択または追加してください。',
|
||||
'Choose a project to get started.': '開始するには、プロジェクトを選択してください。',
|
||||
'Please enter feedback before submitting.': '送信する前に、フィードバックを入力してください。',
|
||||
'Add a Git project under this folder to attach GitHub or GitLab tasks.':
|
||||
'GitHub または GitLab のタスクを関連付けるには、このフォルダー内に Git プロジェクトを追加してください。',
|
||||
'Enter a valid website URL.': '有効な Web サイトの URL を入力してください。',
|
||||
'Choose a WSL distro before projects can inherit WSL.':
|
||||
'プロジェクトが WSL を継承できるようにするには、先に WSL ディストリビューションを選択してください。',
|
||||
'Choose a different base branch before creating a {{value0}}.':
|
||||
'{{value0}} を作成する前に、別のベースブランチを選択してください。',
|
||||
'Optional. Orca can use your normal Codex login; add accounts only if you want quick switching in Orca.':
|
||||
'任意。Orca は通常の Codex ログインを使用できます。Orca 上ですばやく切り替えたい場合にのみ、アカウントを追加してください。',
|
||||
'Optional. Orca can use your normal Claude login; add accounts only if you want quick switching without moving chat sessions.':
|
||||
'任意。Orca は通常の Claude ログインを使用できます。チャットセッションを移さずにすばやく切り替えたい場合にのみ、アカウントを追加してください。',
|
||||
'Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them.':
|
||||
'任意。Orca は既存のプロバイダーのログインと連携します。Orca に切り替えを任せたい場合にのみ、アカウントを追加してください。',
|
||||
'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.':
|
||||
'既定ではこのコンピュータを使用します。対応するプロジェクト、ファイル、ターミナル、プロバイダーチェック、ブラウザ/モバイルの引き継ぎをそのサーバー経由で実行したい場合にのみ、保存済みサーバーを選択してください。',
|
||||
'Window Blur': 'ウィンドウのぼかし',
|
||||
'Medium section heading.': '中サイズのセクション見出し。',
|
||||
'macOS Option key': 'macOS の Option キー',
|
||||
'Failed to add folder on this host': 'このホスト上でのフォルダーの追加に失敗しました',
|
||||
'This workspace is locked by Git. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
|
||||
'このワークスペースは Git によってロックされています。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
|
||||
'This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
|
||||
'このワークスペースは Git によってロックされています。Git の報告: {{value0}}。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
|
||||
'Permanently deletes the remote Git worktrees and their branches. Cannot be undone.':
|
||||
'リモートの Git ワークツリーとそのブランチを完全に削除します。この操作は取り消せません。',
|
||||
'Click a card to copy a prompt. Use these in a Linear-linked worktree after the skill is installed.':
|
||||
'カードをクリックするとプロンプトをコピーできます。スキルのインストール後、Linear にリンクしたワークツリーで使用してください。',
|
||||
'Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.':
|
||||
'チケット操作は、タスクから作成したワークツリーで最も効果的です。Issue がコンテキストとしてリンクされたままになります。',
|
||||
'Choose a base branch.': 'ベースブランチを選択してください。',
|
||||
'Enter a {{value0}} title.': '{{value0}} のタイトルを入力してください。',
|
||||
'Enter a valid five-field cron before saving.':
|
||||
'保存する前に、有効な 5 フィールドの cron を入力してください。',
|
||||
'Choose an available workspace before saving.':
|
||||
'保存する前に、使用可能なワークスペースを選択してください。',
|
||||
'Enter a valid advanced schedule before saving.':
|
||||
'保存する前に、有効な詳細スケジュールを入力してください。',
|
||||
'Creating...': '作成中…',
|
||||
'Creating…': '作成中…',
|
||||
'Reopening...': '再オープン中…',
|
||||
'Thinking…': '思考中…',
|
||||
'Hide from sidebar': 'サイドバーから非表示',
|
||||
'Show in sidebar': 'サイドバーに表示',
|
||||
'Recent or tab strip.': '「最近」またはタブストリップ。',
|
||||
// Settings-search keywords: proper nouns keep their canonical spelling, and the rest were
|
||||
// translated in the wrong sense (windows→窓, gitignore→ギティ無視, component→成分).
|
||||
windows: 'Windows',
|
||||
'windows powershell': 'Windows PowerShell',
|
||||
'powershell 7': 'PowerShell 7',
|
||||
neovim: 'Neovim',
|
||||
hermes: 'Hermes',
|
||||
duckduckgo: 'DuckDuckGo',
|
||||
gitignore: 'gitignore',
|
||||
component: 'コンポーネント',
|
||||
compose: '特殊文字の入力',
|
||||
sidekick: 'Sidekick',
|
||||
'api token': 'API トークン',
|
||||
'compare base': '比較ベース',
|
||||
ahead: '進んでいる'
|
||||
}
|
||||
|
||||
export const JA_VALUE_OVERRIDES = { ...JA_AUDIT_OVERRIDES, ...JA_UNIFIED_VALUES }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
|
||||
import { JA_KEY_OVERRIDES } from './locale-ja-key-overrides.mjs'
|
||||
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
|
||||
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
|
||||
|
||||
@@ -11,6 +12,9 @@ export function mergeLocaleKeyOverrides(base) {
|
||||
// KO split overrides can share keys with zh/ja repairs; merge per locale.
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(JA_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'menu.showMobileButton': {
|
||||
ko: 'Orca 모바일 버튼 표시',
|
||||
zh: '显示 Orca Mobile 按钮',
|
||||
ja: 'Orca モバイル ボタンを表示'
|
||||
ja: 'Orca Mobile ボタンを表示'
|
||||
},
|
||||
'menu.toggleLeftSidebar': {
|
||||
ko: '왼쪽 사이드바 표시/숨기기',
|
||||
@@ -169,7 +169,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
ko: '에이전트 실행',
|
||||
zh: '启动代理',
|
||||
ja: 'エージェントを起動'
|
||||
ja: 'Agent を起動'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.c39ab10000': {
|
||||
ko: 'Linear 작업 열기',
|
||||
@@ -343,7 +343,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'settings.appearance.statusBar.kimiToggleDescription': {
|
||||
ko: '활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.',
|
||||
zh: '显示当前工作区的 Kimi 订阅使用情况。',
|
||||
ja: 'Kimi サブスクリプション'
|
||||
ja: 'アクティブなワークスペースの Kimi サブスクリプション使用状況を表示します。'
|
||||
},
|
||||
'auto.components.mobile.MobileHero.cd4e5e816f': {
|
||||
ko: '주머니 속의 워크스페이스.',
|
||||
@@ -433,17 +433,17 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.AgentsPane.9bccf48906': {
|
||||
ko: '에이전트 위치',
|
||||
zh: '代理位置',
|
||||
ja: 'エージェントの場所'
|
||||
ja: 'Agent の場所'
|
||||
},
|
||||
'auto.components.skills.SkillsPage.38e0951c3a': {
|
||||
ko: '에이전트 스킬',
|
||||
zh: '代理技能',
|
||||
ja: 'エージェントのスキル'
|
||||
ja: 'Agent のスキル'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.e518f544b1': {
|
||||
ko: '감지된 에이전트 없음',
|
||||
zh: '未检测到代理',
|
||||
ja: 'エージェントが検出されません'
|
||||
ja: 'Agent が検出されません'
|
||||
},
|
||||
'auto.components.onboarding.OnboardingFlow.04ae28d8ca': {
|
||||
ko: '몇 시간 내내 보고 싶은 테마를 선택하세요.',
|
||||
@@ -478,7 +478,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.ExperimentalPane.0277901cf7': {
|
||||
ko: '완료된 에이전트, 차단 질문, 읽지 않은 상태 및 작업 트리 생성 이벤트에 대한 스레드 작업 트리 피드가 있는 에이전트 항목을 왼쪽 사이드바에 추가합니다. 실험적 — 이벤트 모델과 UI가 변경될 수 있습니다.',
|
||||
zh: '将代理条目添加到左侧边栏,其中包含已完成代理、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。',
|
||||
ja: '完了したエージェント、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付きエージェント項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
|
||||
ja: '完了した Agent、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付き Agent 項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
|
||||
},
|
||||
'auto.lib.fix.checks.agent.launch.9f00d7df0c': {
|
||||
ko: '검사 프롬프트가 비어 있습니다. 소스 제어 AI 설정을 업데이트하세요.',
|
||||
@@ -488,7 +488,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.TaskPage.d0e3c8f933': {
|
||||
ko: '일치하는 GitHub 작업이 없습니다',
|
||||
zh: '没有匹配的 GitHub 工作项',
|
||||
ja: '一致する GitHub 作業がありません'
|
||||
ja: '一致する GitHub の作業はありません'
|
||||
},
|
||||
'auto.components.TaskPage.2af3ab5c58': {
|
||||
ko: 'Linear에서 열 팀을 하나 선택하세요',
|
||||
@@ -513,7 +513,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.IntegrationsPane.33ae9730a8': {
|
||||
ko: 'Linear 액세스를 추가해 이슈를 탐색하고 연결합니다.',
|
||||
zh: '添加 Linear 访问以浏览和链接议题。',
|
||||
ja: 'Linear アクセスを追加してイシューを参照し、リンクします。'
|
||||
ja: 'Linear アクセスを追加して、Issue を閲覧およびリンクします。'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.3ab6ac0fc8': {
|
||||
ko: '선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// CSS that lives in the i18n catalog. MT rewrote a selector to [データスラッシュメニュー] and a keyframe
|
||||
// name to ブラウザフラッシュ, breaking the view in that locale only. Removing these from en.json is
|
||||
// the real fix; until then the policy pins them to English.
|
||||
|
||||
const STYLE_BLOCK =
|
||||
/@(keyframes|media|supports|font-face)\b|[.#][a-zA-Z][\w-]*[^{}]*\{[^{}]*[a-z-]+\s*:|\[[a-z-]+=["'][^"']*["']\]|\{[^{}]*[a-z-]+\s*:\s*[^{}]*[;}]/
|
||||
|
||||
// A selector with no declaration block has no braces for STYLE_BLOCK to key off.
|
||||
const SELECTOR_TOKEN = /^(?:[>+~]|[a-zA-Z.#[][\w.#:()[\]"'=^$|*-]*)$/
|
||||
const LONE_SELECTOR = /^#[a-zA-Z][\w-]*$|^\.[a-zA-Z][\w-]*-[\w-]+$/
|
||||
// One dotted, colon or bracketed token — button.primary, a:hover, wsl.exe, localhost:3000. It is
|
||||
// code whether it names a selector, a file or a host, and a translation breaks all three.
|
||||
const LONE_QUALIFIED = /^[a-zA-Z][\w-]*(?:[.#:][\w-]+|\[[^\]]+\])$/
|
||||
|
||||
function isStandaloneSelector(enValue) {
|
||||
if (/[{}]/.test(enValue)) {
|
||||
return false
|
||||
}
|
||||
const tokens = enValue.trim().split(/\s+/)
|
||||
if (!tokens.every((token) => SELECTOR_TOKEN.test(token))) {
|
||||
return false
|
||||
}
|
||||
// A selector join, not a sentence period: two sentences would otherwise clear the threshold.
|
||||
const marked = tokens.filter((token) => /^[#.][a-zA-Z]|[\w-][.#:][a-zA-Z]|\[/.test(token))
|
||||
if (marked.length >= 2) {
|
||||
return true
|
||||
}
|
||||
return tokens.length === 1 && (LONE_SELECTOR.test(tokens[0]) || LONE_QUALIFIED.test(tokens[0]))
|
||||
}
|
||||
|
||||
export function isStyleValue(enValue) {
|
||||
return STYLE_BLOCK.test(enValue) || isStandaloneSelector(enValue)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairTranslatedValue, shouldPreserveEnglishValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const ja = (enValue, localeValue, key = 'auto.test.relocalization') =>
|
||||
repairTranslatedValue({ key, enValue, localeValue, locale: 'ja' })
|
||||
|
||||
describe('locale-translation-policy ja relocalization', () => {
|
||||
it('heals generic terms the brand revert left in Latin mid-sentence', () => {
|
||||
expect(ja('Close terminal?', 'この terminal を閉じますか?')).toBe('このターミナルを閉じますか?')
|
||||
expect(ja('New Terminal', '新規 Terminal')).toBe('新規ターミナル')
|
||||
expect(ja('Stop the agent?', 'この agent を停止しますか?')).toBe('この Agent を停止しますか?')
|
||||
expect(ja('Nothing to commit.', 'commit するものはありません。')).toBe(
|
||||
'コミットするものはありません。'
|
||||
)
|
||||
expect(ja('Open the repo', 'repo を開く')).toBe('リポジトリを開く')
|
||||
})
|
||||
|
||||
it('leaves the second word of a two-word command in Latin', () => {
|
||||
expect(ja('a commit hook or git commit fails', 'コミットフックまたは git commit が失敗')).toBe(
|
||||
'コミットフックまたは git commit が失敗'
|
||||
)
|
||||
expect(ja('Run orca terminal', 'orca terminal を実行')).toBe('orca terminal を実行')
|
||||
expect(ja('Install with pnpm install', 'pnpm install でインストール')).toBe(
|
||||
'pnpm install でインストール'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves identifiers, flags, filenames, and Latin-only labels alone', () => {
|
||||
expect(ja('Pass --agent to select one.', '--agent を渡して選択します。')).toBe(
|
||||
'--agent を渡して選択します。'
|
||||
)
|
||||
expect(ja('Edit agents.md', 'agents.md を編集')).toBe('agents.md を編集')
|
||||
expect(ja('Agent SDK settings', 'Agent SDK の設定')).toBe('Agent SDK の設定')
|
||||
expect(ja('Agent SDK settings', 'この Agent SDK の設定')).toBe('この Agent SDK の設定')
|
||||
expect(ja('Run git commit -m', 'git commit -m を実行')).toBe('git commit -m を実行')
|
||||
})
|
||||
|
||||
it('keeps commands and identifiers in English so they still run', () => {
|
||||
expect(shouldPreserveEnglishValue('pnpm install')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('glab auth login')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue("gh pr list --json number -q '.[0].number'")).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('--model sonnet')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('localhost, 127.0.0.1, *.internal')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('stale-agent-row-{{value0}}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('text-foreground')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{basePrompt}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{firstPrompt}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{assistantMessage}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('/pricing')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('/signup')).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs translated code examples back to executable values', () => {
|
||||
expect(ja('--model sonnet', '--モデルソネット')).toBe('--model sonnet')
|
||||
expect(
|
||||
ja("gh pr list --json number -q '.[0].number'", "gh pr list --json 番号 -q '.[0].number'")
|
||||
).toBe("gh pr list --json number -q '.[0].number'")
|
||||
expect(ja('{basePrompt}', '{ベースプロンプト}')).toBe('{basePrompt}')
|
||||
expect(ja('/pricing', '/価格設定')).toBe('/pricing')
|
||||
})
|
||||
|
||||
it('keeps style blocks in English because a translated selector breaks the view', () => {
|
||||
expect(shouldPreserveEnglishValue('.pp-card[data-card="starter"] .pp-cta')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('@keyframes browser-flash { to { opacity: 1; } }')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('.card { background: var(--card); }')).toBe(true)
|
||||
// Why: the declaration requirement is what keeps prose containing braces translatable.
|
||||
expect(shouldPreserveEnglishValue('e.g. {{value0}}')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes Japanese typography and settled terminology', () => {
|
||||
expect(ja('Loading…', '読み込み中...')).toBe('読み込み中…')
|
||||
expect(ja('Merge conflict', 'マージ紛争')).toBe('マージ競合')
|
||||
// Why: MT inserts a Microsoft-style separator space that Japanese compounds do not use.
|
||||
expect(ja('Status bar', 'ステータス バー')).toBe('ステータスバー')
|
||||
expect(ja('Missing credentials', '資格情報がありません')).toBe('認証情報がありません')
|
||||
expect(ja('Unknown', '未知')).toBe('不明')
|
||||
expect(ja('Restart the server', 'サーバを再起動')).toBe('サーバーを再起動')
|
||||
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
|
||||
})
|
||||
|
||||
it('leaves ranges and token samples out of the ellipsis rule', () => {
|
||||
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
|
||||
})
|
||||
|
||||
it('spaces the Latin terms the phrase fixes write back in', () => {
|
||||
expect(ja('Clear cookies', 'クッキーを消去')).toBe('Cookie を消去')
|
||||
expect(ja('Safely fast-forward the branch', 'ブランチを安全に早送りします')).toBe(
|
||||
'ブランチを安全に fast-forward します'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses ホスト for host, and leaves サーバー alone when English says server', () => {
|
||||
expect(ja('Host path', 'サーバーパス')).toBe('ホストパス')
|
||||
expect(ja('Enter a path that exists on the selected host.', 'ランタイムサーバー上のパス')).toBe(
|
||||
'ホスト上のパス'
|
||||
)
|
||||
// Why: host and server are deliberately distinct wherever the English uses both.
|
||||
expect(ja('Save the server host', 'サーバーを保存')).toBe('サーバーを保存')
|
||||
})
|
||||
|
||||
it('restores Git vocabulary and brand names the MT localized', () => {
|
||||
expect(ja('Branch upstream', 'ブランチの上流')).toBe('ブランチの upstream')
|
||||
expect(ja('origin has commits', '起源にコミットがあります')).toBe('origin にコミットがあります')
|
||||
expect(ja('Discard all staged changes?', '段階的な変更を破棄しますか?')).toBe(
|
||||
'ステージ済みの変更を破棄しますか?'
|
||||
)
|
||||
expect(ja('Create Hermes automation', 'エルメスオートメーションを作成')).toBe(
|
||||
'Hermes オートメーションを作成'
|
||||
)
|
||||
expect(ja('Delete the worktree', 'worktree を削除')).toBe('ワークツリーを削除')
|
||||
})
|
||||
|
||||
it('keeps class strings, refs, and fixture filenames in English', () => {
|
||||
for (const value of [
|
||||
'size-4 text-muted-foreground',
|
||||
'text-amber-700 dark:text-amber-300',
|
||||
'origin/main',
|
||||
'upstream/main',
|
||||
'login.spec.ts',
|
||||
'bastion.example.com'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a selector that has no declaration block', () => {
|
||||
for (const value of [
|
||||
'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta',
|
||||
'.pp-cta',
|
||||
'#dialog',
|
||||
'.pp-card[data-card="starter"] .pp-cta',
|
||||
'button.primary',
|
||||
'a:hover',
|
||||
'input[type="text"]',
|
||||
'localhost:3000'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(true)
|
||||
}
|
||||
// Why: two ordinary sentences carry two periods, which must not read as selector joins.
|
||||
for (const value of [
|
||||
'Open Settings > Git and try again.',
|
||||
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.',
|
||||
'orca.yaml + local',
|
||||
'.gitignore',
|
||||
'Status:',
|
||||
'Done.',
|
||||
'#{{value0}}'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -108,18 +108,18 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
enValue: 'Terminals',
|
||||
localeValue: '端子',
|
||||
localeValue: '端末',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Terminals')
|
||||
).toBe('ターミナル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
enValue: 'Agent Skills',
|
||||
localeValue: 'エージェントのスキル',
|
||||
localeValue: 'Agent のスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('エージェントのスキル')
|
||||
).toBe('Agent のスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
|
||||
import { isStyleValue } from './locale-style-values.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
import { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
||||
import { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
|
||||
|
||||
export { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
|
||||
export { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
export { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
export { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
||||
@@ -164,167 +167,55 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'lint',
|
||||
'MD',
|
||||
'/home/user/projects',
|
||||
'Claude Code'
|
||||
'Claude Code',
|
||||
// Commands, refs, class strings and code samples: a translated one no longer runs or matches.
|
||||
'pnpm install',
|
||||
'glab auth login',
|
||||
"gh pr list --json number -q '.[0].number'",
|
||||
'--model sonnet',
|
||||
'localhost, 127.0.0.1, *.internal',
|
||||
'packages/web shared/ui',
|
||||
'stale-agent-row-{{value0}}',
|
||||
'text-foreground',
|
||||
'source-control',
|
||||
'combined-branch',
|
||||
'pr-view',
|
||||
'fix-login-flow',
|
||||
'my-project',
|
||||
'serve-sim',
|
||||
'pnpm playwright test',
|
||||
'gh auth login',
|
||||
'size-4 text-muted-foreground',
|
||||
'text-amber-700 dark:text-amber-300',
|
||||
'text-emerald-700 dark:text-emerald-300',
|
||||
'src/renderer packages/ui',
|
||||
'upstream/main',
|
||||
'origin/main',
|
||||
'example.com',
|
||||
'bastion.example.com',
|
||||
'dashboard.spec.ts',
|
||||
'checkout.spec.ts',
|
||||
'login.spec.ts',
|
||||
'untitled.md',
|
||||
'review src/auth',
|
||||
'throw src/auth',
|
||||
// Rendered inside <code> or a font-mono element, so they are code the user copies or types.
|
||||
'{prompt}',
|
||||
'{basePrompt}',
|
||||
'{firstPrompt}',
|
||||
'{assistantMessage}',
|
||||
'/goal',
|
||||
'/pricing',
|
||||
'/signup',
|
||||
'npm run dev',
|
||||
'nbformat',
|
||||
'orca.yaml',
|
||||
'upstream',
|
||||
'LIN-329',
|
||||
'GH #1799',
|
||||
'orca · zsh'
|
||||
])
|
||||
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
ko: {
|
||||
Codex: ['사본', '코덱스'],
|
||||
Gemini: ['쌍둥이자리'],
|
||||
Claude: ['클로드'],
|
||||
Grok: ['그록'],
|
||||
Orca: ['오르카', '범고래'],
|
||||
Cursor: ['커서'],
|
||||
OpenCode: ['오픈코드'],
|
||||
OpenClaw: ['오픈클로'],
|
||||
OpenClaude: ['오픈클로드'],
|
||||
Antigravity: ['반중력'],
|
||||
Continue: ['계속하다'],
|
||||
Charm: ['매력'],
|
||||
Goose: ['거위'],
|
||||
Pi: ['파이'],
|
||||
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
||||
Git: ['힘내'],
|
||||
Discord: ['디스코드'],
|
||||
Linear: ['선형'],
|
||||
Agent: ['에이전트'],
|
||||
Agents: ['에이전트'],
|
||||
agent: ['에이전트'],
|
||||
agents: ['에이전트'],
|
||||
Commit: ['커밋'],
|
||||
Commits: ['커밋'],
|
||||
commit: ['커밋'],
|
||||
commits: ['커밋'],
|
||||
Markdown: ['마크다운', '가격 인하'],
|
||||
markdown: ['마크다운', '가격 인하'],
|
||||
Repo: ['저장소', '레포'],
|
||||
Repos: ['저장소', '레포'],
|
||||
repo: ['저장소', '레포'],
|
||||
repos: ['저장소', '레포'],
|
||||
Terminal: ['터미널'],
|
||||
Terminals: ['터미널'],
|
||||
terminal: ['터미널'],
|
||||
terminals: ['터미널']
|
||||
},
|
||||
zh: {
|
||||
Codex: ['法典'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['克洛德', '克劳德'],
|
||||
Grok: ['格罗克'],
|
||||
Orca: ['虎鲸', '逆戟鲸'],
|
||||
Cursor: ['光标'],
|
||||
OpenCode: ['开放代码'],
|
||||
OpenClaw: ['开爪'],
|
||||
OpenClaude: ['开放克劳德'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['继续'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['鹅'],
|
||||
Pi: ['圆周率'],
|
||||
Droid: ['机器人'],
|
||||
Kimi: ['基米'],
|
||||
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
|
||||
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
|
||||
moonshot: ['登月计划'],
|
||||
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
||||
Bitbucket: ['位桶'],
|
||||
Linear: ['线性', '线形'],
|
||||
Jira: ['吉拉'],
|
||||
Tailscale: ['尾鳞', '尾鱗'],
|
||||
Agent: ['代理', '智能体'],
|
||||
Agents: ['代理', '智能体'],
|
||||
agent: ['代理', '智能体'],
|
||||
agents: ['代理', '智能体'],
|
||||
Commit: ['提交'],
|
||||
Commits: ['提交'],
|
||||
commit: ['提交'],
|
||||
commits: ['提交'],
|
||||
Markdown: ['降价'],
|
||||
markdown: ['降价'],
|
||||
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Terminal: ['终端', '端子'],
|
||||
Terminals: ['终端', '端子'],
|
||||
terminal: ['终端', '端子'],
|
||||
terminals: ['终端', '端子'],
|
||||
Bash: ['重击'],
|
||||
PowerShell: ['电源外壳'],
|
||||
REST: ['休息'],
|
||||
HEAD: ['头'],
|
||||
Swift: ['迅速'],
|
||||
Rust: ['锈'],
|
||||
'Claude Code': ['Claude·科德'],
|
||||
'Git AI Author': ['Git AI 作者']
|
||||
},
|
||||
ja: {
|
||||
Codex: ['法典', 'コーデックス'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['クロード'],
|
||||
Grok: ['グロック'],
|
||||
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
||||
Cursor: ['カーソル'],
|
||||
OpenCode: ['オープンコード', 'オープン・コード'],
|
||||
OpenClaw: ['オープンクロー'],
|
||||
OpenClaude: ['オープンクロード'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['続ける', '続行'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['ガチョウ', '雁'],
|
||||
Pi: ['円周率'],
|
||||
Droid: ['ロボット', 'ドロイド'],
|
||||
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
||||
Discord: ['不和'],
|
||||
Linear: ['線形'],
|
||||
Agent: ['エージェント'],
|
||||
Agents: ['エージェント'],
|
||||
agent: ['エージェント'],
|
||||
agents: ['エージェント'],
|
||||
Commit: ['コミット'],
|
||||
Commits: ['コミット'],
|
||||
commit: ['コミット'],
|
||||
commits: ['コミット'],
|
||||
Markdown: ['マークダウン'],
|
||||
markdown: ['マークダウン'],
|
||||
Repo: ['リポジトリ', 'リポ'],
|
||||
Repos: ['リポジトリ', 'リポ'],
|
||||
repo: ['リポジトリ', 'リポ'],
|
||||
repos: ['リポジトリ', 'リポ'],
|
||||
Terminal: ['ターミナル', '端子'],
|
||||
Terminals: ['ターミナル', '端子'],
|
||||
terminal: ['ターミナル', '端子'],
|
||||
terminals: ['ターミナル', '端子']
|
||||
},
|
||||
es: {
|
||||
Codex: ['códice', 'Códice'],
|
||||
Gemini: ['Géminis'],
|
||||
Claude: ['claudia', 'Claudia'],
|
||||
Orca: ['orca', 'Orcas', 'orcas'],
|
||||
OpenCode: ['código abierto', 'Código abierto'],
|
||||
OpenClaude: ['Openclaude'],
|
||||
Antigravity: ['antigravedad', 'Antigravedad'],
|
||||
'GitHub Copilot': ['Copiloto de GitHub'],
|
||||
Discord: ['discordia'],
|
||||
Linear: ['lineal', 'Lineal'],
|
||||
Jira: ['jira'],
|
||||
Agent: ['Agente', 'agente'],
|
||||
Agents: ['Agentes', 'agentes'],
|
||||
agent: ['agente'],
|
||||
agents: ['agentes'],
|
||||
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
||||
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
||||
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
||||
commits: ['confirmaciones', 'compromisos'],
|
||||
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
||||
markdown: ['reducción', 'rebaja', 'rebajas'],
|
||||
Repo: ['Repositorio', 'repositorio'],
|
||||
Repos: ['Repositorios', 'repositorios'],
|
||||
repo: ['repositorio'],
|
||||
repos: ['repositorios']
|
||||
}
|
||||
}
|
||||
|
||||
export const NATIVE_PICKER_LABELS = {
|
||||
zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
||||
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
||||
@@ -348,6 +239,9 @@ export function shouldPreserveEnglishValue(enValue, key = '') {
|
||||
if (isEnglishOnlyKey(key)) {
|
||||
return true
|
||||
}
|
||||
if (isStyleValue(enValue)) {
|
||||
return true
|
||||
}
|
||||
return NEVER_TRANSLATE_VALUES.has(enValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
).toBe('失敗したチェックに対して AI Agent を開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
).toBe('スマートフォンからターミナルと Agent を操作します。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -398,7 +398,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コンピューターと携帯電話で。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('コンピューターとスマートフォンで。')
|
||||
).toBe('コンピュータとスマートフォンで。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.3ab6ac0fc8',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.177-rc.0",
|
||||
"version": "1.4.178-rc.2",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
Generated
+24
-24
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8
|
||||
hash: 8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -48,7 +48,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -211,25 +211,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
version: 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9594,39 +9594,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
@@ -626,6 +626,19 @@
|
||||
"orca-per-workspace-env": 5,
|
||||
"orchestration": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.178-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 8,
|
||||
"linear-tickets": 10,
|
||||
"orca-cli": 36,
|
||||
"orca-emulator": 7,
|
||||
"orca-emulator-android": 5,
|
||||
"orca-linear": 8,
|
||||
"orca-per-workspace-env": 5,
|
||||
"orchestration": 28
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ArtifactWriteRequest } from '../../shared/artifacts'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
|
||||
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
|
||||
return {
|
||||
content: request.content,
|
||||
contentType: request.contentType,
|
||||
fileName: request.fileName,
|
||||
...(request.title ? { title: request.title } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function artifactRequest<T>(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
|
||||
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
||||
...(options.body ? { 'content-type': 'application/json' } : {})
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(20_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { code?: string } | null
|
||||
throw new OrcaCloudRequestError(response.status, body?.code)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
@@ -58,6 +58,30 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ArtifactCloudService same-source races', () => {
|
||||
it('runs the next same-source operation after an earlier failure', async () => {
|
||||
const service = await setup()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 'temporary_failure' }), {
|
||||
status: 500,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const failedShare = service.share(writeRequest)
|
||||
const nextShare = service.share(writeRequest)
|
||||
|
||||
await expect(failedShare).rejects.toMatchObject({ statusCode: 500 })
|
||||
await expect(nextShare).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { artifact: { slug: 'artifact-b' } }
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not let an old update overwrite a newer share mapping', async () => {
|
||||
const service = await setup()
|
||||
let resolveUpdate: ((response: Response) => void) | undefined
|
||||
@@ -77,9 +101,11 @@ describe('ArtifactCloudService same-source races', () => {
|
||||
await service.share(writeRequest)
|
||||
const oldUpdate = service.update(writeRequest)
|
||||
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
|
||||
await service.share(writeRequest)
|
||||
const newerShare = service.share(writeRequest)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
resolveUpdate?.(createResponse('artifact-a'))
|
||||
await oldUpdate
|
||||
await newerShare
|
||||
await service.update(writeRequest)
|
||||
|
||||
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
|
||||
@@ -108,9 +134,11 @@ describe('ArtifactCloudService same-source races', () => {
|
||||
authToken: 'token-a'
|
||||
})
|
||||
await vi.waitFor(() => expect(resolveDelete).toBeTypeOf('function'))
|
||||
await service.share(writeRequest)
|
||||
const newerShare = service.share(writeRequest)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
resolveDelete?.(new Response(null, { status: 204 }))
|
||||
await oldUnshare
|
||||
await newerShare
|
||||
await service.update(writeRequest)
|
||||
|
||||
expect(String(fetchMock.mock.calls[3]?.[0])).toBe(`${apiUrl}/v1/artifacts/artifact-b`)
|
||||
|
||||
@@ -143,6 +143,165 @@ describe('ArtifactCloudService record authorization', () => {
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
|
||||
it('creates once and updates on repeated publish', async () => {
|
||||
const { service } = await setup()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'created', item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' } }
|
||||
})
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'updated', item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' } }
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' })
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`${apiUrl}/v1/artifacts/artifact-a`)
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' })
|
||||
})
|
||||
|
||||
it('resolves a persisted public link only for its source and cloud scope', async () => {
|
||||
const { service } = await setup()
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(createResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await service.publish(writeRequest)
|
||||
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: '/repo/other.html', apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({ status: 'ok', value: null })
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-b' })
|
||||
).resolves.toEqual({ status: 'ok', value: null })
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('serializes concurrent publishes for the same source', async () => {
|
||||
const { service } = await setup()
|
||||
let resolveCreate: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const first = service.publish(writeRequest)
|
||||
const second = service.publish(writeRequest)
|
||||
await vi.waitFor(() => expect(resolveCreate).toBeTypeOf('function'))
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
resolveCreate?.(createResponse())
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([
|
||||
{ status: 'ok', value: { change: 'created' } },
|
||||
{ status: 'ok', value: { change: 'updated' } }
|
||||
])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: 'PUT' })
|
||||
})
|
||||
|
||||
it('serializes manual publish with CLI share for the same source', async () => {
|
||||
const { service } = await setup()
|
||||
let resolvePublish: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolvePublish = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const publish = service.publish(writeRequest)
|
||||
const share = service.share(writeRequest)
|
||||
await vi.waitFor(() => expect(resolvePublish).toBeTypeOf('function'))
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
resolvePublish?.(createResponse('artifact-a'))
|
||||
|
||||
await expect(Promise.all([publish, share])).resolves.toMatchObject([
|
||||
{ status: 'ok', value: { change: 'created' } },
|
||||
{ status: 'ok', value: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
|
||||
])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('serializes account deletion with a mapped source update', async () => {
|
||||
const { service } = await setup()
|
||||
let resolveUpdate: ((response: Response) => void) | undefined
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResponse())
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await service.share(writeRequest)
|
||||
|
||||
const update = service.update(writeRequest)
|
||||
await vi.waitFor(() => expect(resolveUpdate).toBeTypeOf('function'))
|
||||
const deletion = service.delete('artifact-a', { apiUrl, authToken: 'token-a' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
resolveUpdate?.(createResponse())
|
||||
|
||||
await expect(Promise.all([update, deletion])).resolves.toMatchObject([
|
||||
{ status: 'ok' },
|
||||
{ status: 'ok' }
|
||||
])
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({ status: 'ok', value: null })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('recreates an artifact when its stored public link was deleted elsewhere', async () => {
|
||||
const { service } = await setup()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createResponse('artifact-a'))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ code: 'not_found' }), { status: 404 }))
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
.mockResolvedValueOnce(createResponse('artifact-b'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await service.publish(writeRequest)
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'created', item: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
|
||||
})
|
||||
await expect(service.publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { change: 'updated', item: { shareUrl: 'https://share.onorca.dev/a/artifact-b' } }
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls.map(([, options]) => options?.method)).toEqual([
|
||||
'POST',
|
||||
'PUT',
|
||||
'POST',
|
||||
'PUT'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the idempotency key stable across an auth-refresh retry', async () => {
|
||||
const { service, profileId, userDataPath } = await setup()
|
||||
vi.stubEnv('ORCA_CLOUD_API_URL', 'http://localhost:4100')
|
||||
@@ -327,6 +486,7 @@ describe('ArtifactCloudService publish capability gate', () => {
|
||||
|
||||
it.each([
|
||||
['share', (service: ArtifactCloudService) => service.share(writeRequest)],
|
||||
['publish', (service: ArtifactCloudService) => service.publish(writeRequest)],
|
||||
['update', (service: ArtifactCloudService) => service.update(writeRequest)]
|
||||
])('rejects %s without reaching the network when the capability is off', async (_name, call) => {
|
||||
const { service } = await setup({ value: false })
|
||||
@@ -381,6 +541,12 @@ describe('ArtifactCloudService publish capability gate', () => {
|
||||
await expect(service.list({ apiUrl, authToken: 'token-a' })).resolves.toMatchObject({
|
||||
status: 'ok'
|
||||
})
|
||||
await expect(
|
||||
service.getPublishedLink({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({
|
||||
status: 'ok',
|
||||
value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
await expect(
|
||||
service.unshare({ sourceKey: writeRequest.sourceKey, apiUrl, authToken: 'token-a' })
|
||||
).resolves.toEqual({ status: 'ok', value: undefined })
|
||||
|
||||
@@ -5,12 +5,13 @@ import type {
|
||||
ArtifactListOptions,
|
||||
ArtifactListPage,
|
||||
ArtifactListItem,
|
||||
ArtifactPublishedLink,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
|
||||
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
|
||||
import {
|
||||
allowsArtifactCloudAuthOverride,
|
||||
@@ -22,12 +23,11 @@ import {
|
||||
getArtifactShareRecord,
|
||||
isArtifactShareLifecycleCurrent,
|
||||
refreshArtifactShareRecordExpiration,
|
||||
removeArtifactShareRecords,
|
||||
saveArtifactShareRecord
|
||||
removeArtifactShareRecords
|
||||
} from './artifact-share-record-store'
|
||||
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
|
||||
|
||||
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
|
||||
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
|
||||
import { ArtifactPublisher } from './artifact-publisher'
|
||||
|
||||
type ArtifactAuthContext = {
|
||||
profileId: string
|
||||
@@ -114,6 +114,8 @@ function explicitTokenAuthContext(
|
||||
}
|
||||
|
||||
export class ArtifactCloudService {
|
||||
private readonly publisher: ArtifactPublisher
|
||||
|
||||
/**
|
||||
* `isSharingEnabled` is the publish capability gate. It is read per call, never cached, so
|
||||
* revoking it in Settings takes effect on the next request. List, unshare, and delete stay
|
||||
@@ -122,7 +124,9 @@ export class ArtifactCloudService {
|
||||
constructor(
|
||||
private readonly userDataPath: string,
|
||||
private readonly isSharingEnabled: () => boolean
|
||||
) {}
|
||||
) {
|
||||
this.publisher = new ArtifactPublisher(userDataPath)
|
||||
}
|
||||
|
||||
list(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
@@ -131,88 +135,121 @@ export class ArtifactCloudService {
|
||||
})
|
||||
}
|
||||
|
||||
getPublishedLink(
|
||||
request: ArtifactCloudOptions & { sourceKey: string }
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
|
||||
return this.withAuth(request, async (_token, _apiUrl, auth) => {
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
return record ? { shareUrl: record.shareUrl } : null
|
||||
})
|
||||
}
|
||||
|
||||
// Why async: the gate must surface as a rejection, not a synchronous throw, so every caller's
|
||||
// promise chain handles it the same way.
|
||||
async share(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
assertArtifactSharingAllowed(this.isSharingEnabled)
|
||||
const idempotencyKey = randomUUID()
|
||||
return this.withAuth(request, async (token, apiUrl, auth) => {
|
||||
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
|
||||
method: 'POST',
|
||||
body: writeBody(request),
|
||||
idempotencyKey
|
||||
})
|
||||
auth.assertCurrent()
|
||||
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
|
||||
slug: response.artifact.slug,
|
||||
editToken: response.editToken,
|
||||
shareUrl: response.shareUrl,
|
||||
expiresAt: response.artifact.expiresAt,
|
||||
...auth.scope
|
||||
})
|
||||
return { artifact: response.artifact, shareUrl: response.shareUrl }
|
||||
})
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.share(request, token, apiUrl, auth, idempotencyKey)
|
||||
)
|
||||
}
|
||||
|
||||
async publish(
|
||||
request: ArtifactWriteRequest
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
|
||||
assertArtifactSharingAllowed(this.isSharingEnabled)
|
||||
const idempotencyKey = randomUUID()
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.publish(request, token, apiUrl, auth, idempotencyKey)
|
||||
)
|
||||
}
|
||||
|
||||
async update(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
assertArtifactSharingAllowed(this.isSharingEnabled)
|
||||
return this.withAuth(request, async (token, apiUrl, auth) => {
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
const response = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: writeBody(request)
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
return this.publisher.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const response = await artifactRequest<ArtifactListItem>(
|
||||
apiUrl,
|
||||
token,
|
||||
`/${record.slug}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: artifactWriteBody(request)
|
||||
}
|
||||
)
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
response.artifact.expiresAt
|
||||
)
|
||||
return response
|
||||
})
|
||||
})
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
response.artifact.expiresAt
|
||||
)
|
||||
return response
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
unshare(
|
||||
request: ArtifactCloudOptions & { sourceKey: string }
|
||||
): Promise<ArtifactCloudOperation<void>> {
|
||||
return this.withAuth(request, async (token, apiUrl, auth) => {
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'DELETE',
|
||||
editToken: record.editToken
|
||||
return this.withAuth(request, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (!record) {
|
||||
throw new Error('This file has not been shared from the active Orca profile.')
|
||||
}
|
||||
return this.publisher.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'DELETE',
|
||||
editToken: record.editToken
|
||||
})
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
})
|
||||
})
|
||||
})
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
delete(id: string, options: ArtifactCloudOptions): Promise<ArtifactCloudOperation<void>> {
|
||||
return this.withAuth(options, async (token, apiUrl, auth) => {
|
||||
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
|
||||
})
|
||||
return this.withAuth(options, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSlug(id, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private async withAuth<T>(
|
||||
@@ -256,40 +293,3 @@ export class ArtifactCloudService {
|
||||
: { status: 'reconnect-required' }
|
||||
}
|
||||
}
|
||||
|
||||
function writeBody(request: ArtifactWriteRequest): Record<string, string> {
|
||||
return {
|
||||
content: request.content,
|
||||
contentType: request.contentType,
|
||||
fileName: request.fileName,
|
||||
...(request.title ? { title: request.title } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function artifactRequest<T>(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown; editToken?: string; idempotencyKey?: string } = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${apiUrl}/v1/artifacts${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.editToken ? { 'x-orca-edit-token': options.editToken } : {}),
|
||||
...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}),
|
||||
...(options.body ? { 'content-type': 'application/json' } : {})
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(20_000)
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { code?: string } | null
|
||||
throw new OrcaCloudRequestError(response.status, body?.code)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
ArtifactListItem,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
|
||||
import {
|
||||
type ArtifactShareScope,
|
||||
getArtifactShareRecord,
|
||||
refreshArtifactShareRecordExpiration,
|
||||
removeArtifactShareRecords,
|
||||
saveArtifactShareRecord
|
||||
} from './artifact-share-record-store'
|
||||
|
||||
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
|
||||
|
||||
type ArtifactPublishAuthContext = {
|
||||
profileId: string
|
||||
scope: ArtifactShareScope
|
||||
assertCurrent: () => void
|
||||
}
|
||||
|
||||
function artifactOperationQueueKey(
|
||||
kind: 'source' | 'slug',
|
||||
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
|
||||
identity: string
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
kind,
|
||||
auth.profileId,
|
||||
auth.scope.cloudUserId,
|
||||
auth.scope.cloudProfileId,
|
||||
auth.scope.cloudOrganizationId,
|
||||
auth.scope.apiOrigin,
|
||||
identity
|
||||
])
|
||||
}
|
||||
|
||||
export class ArtifactPublisher {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(private readonly userDataPath: string) {}
|
||||
|
||||
async share(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactListItem> {
|
||||
return this.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item
|
||||
})
|
||||
}
|
||||
|
||||
publish(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactPublishResult> {
|
||||
return this.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (record) {
|
||||
try {
|
||||
return await this.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: artifactWriteBody(request)
|
||||
})
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
item.artifact.expiresAt
|
||||
)
|
||||
return { change: 'updated', item }
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) {
|
||||
throw error
|
||||
}
|
||||
auth.assertCurrent()
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.create(request, token, apiUrl, auth, idempotencyKey)
|
||||
})
|
||||
}
|
||||
|
||||
runForSource<T>(
|
||||
sourceKey: string,
|
||||
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.runSerialized(artifactOperationQueueKey('source', auth, sourceKey), operation)
|
||||
}
|
||||
|
||||
runForSlug<T>(
|
||||
slug: string,
|
||||
auth: Pick<ArtifactPublishAuthContext, 'profileId' | 'scope'>,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
|
||||
}
|
||||
|
||||
private async create(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactPublishResult> {
|
||||
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
|
||||
method: 'POST',
|
||||
body: artifactWriteBody(request),
|
||||
idempotencyKey
|
||||
})
|
||||
auth.assertCurrent()
|
||||
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
|
||||
slug: response.artifact.slug,
|
||||
editToken: response.editToken,
|
||||
shareUrl: response.shareUrl,
|
||||
expiresAt: response.artifact.expiresAt,
|
||||
...auth.scope
|
||||
})
|
||||
return {
|
||||
change: 'created',
|
||||
item: { artifact: response.artifact, shareUrl: response.shareUrl }
|
||||
}
|
||||
}
|
||||
|
||||
private async runSerialized<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(key) ?? Promise.resolve()
|
||||
let release = (): void => {}
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const ready = previous.catch(() => {})
|
||||
const current = ready.then(() => released)
|
||||
this.queues.set(key, current)
|
||||
await ready
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
release()
|
||||
if (this.queues.get(key) === current) {
|
||||
this.queues.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest'
|
||||
// and update the count.
|
||||
const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
|
||||
// HTTP call sites — body consumed or cancelled on every path, including !ok
|
||||
['main/artifacts/artifact-cloud-service.ts', 1],
|
||||
['main/artifacts/artifact-cloud-request.ts', 1],
|
||||
['main/azure-devops/azure-devops-api-request.ts', 1],
|
||||
['main/bitbucket/client.ts', 1],
|
||||
['main/gitea/client.ts', 1],
|
||||
|
||||
@@ -127,6 +127,8 @@ import type {
|
||||
ArtifactListOptions,
|
||||
ArtifactListPage,
|
||||
ArtifactListItem,
|
||||
ArtifactPublishedLink,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
|
||||
@@ -4660,10 +4662,22 @@ export class OrcaRuntimeService {
|
||||
return this.requireArtifactService().list(options)
|
||||
}
|
||||
|
||||
getPublishedArtifactLink(
|
||||
request: ArtifactCloudOptions & { sourceKey: string }
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishedLink | null>> {
|
||||
return this.requireArtifactService().getPublishedLink(request)
|
||||
}
|
||||
|
||||
shareArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
return this.requireArtifactService().share(request)
|
||||
}
|
||||
|
||||
publishArtifact(
|
||||
request: ArtifactWriteRequest
|
||||
): Promise<ArtifactCloudOperation<ArtifactPublishResult>> {
|
||||
return this.requireArtifactService().publish(request)
|
||||
}
|
||||
|
||||
updateArtifact(request: ArtifactWriteRequest): Promise<ArtifactCloudOperation<ArtifactListItem>> {
|
||||
return this.requireArtifactService().update(request)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { ARTIFACT_METHODS } from './artifacts'
|
||||
|
||||
const validRequest = {
|
||||
sourceKey: '/repo/report.html',
|
||||
content: '<h1>Report</h1>',
|
||||
contentType: 'text/html',
|
||||
fileName: 'report.html'
|
||||
}
|
||||
|
||||
function writeSchema(name: string) {
|
||||
const method = ARTIFACT_METHODS.find((candidate) => candidate.name === name)
|
||||
if (!method?.params) {
|
||||
throw new Error(`Missing ${name} schema`)
|
||||
}
|
||||
return method.params
|
||||
}
|
||||
|
||||
describe('artifact RPC schemas', () => {
|
||||
it('registers the local publish upsert', () => {
|
||||
expect(writeSchema('artifacts.publish').safeParse(validRequest).success).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the persisted-link lookup', () => {
|
||||
const schema = writeSchema('artifacts.getPublishedLink')
|
||||
expect(schema.safeParse({ sourceKey: validRequest.sourceKey }).success).toBe(true)
|
||||
expect(schema.safeParse({ sourceKey: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty and oversized artifact requests', () => {
|
||||
const schema = writeSchema('artifacts.publish')
|
||||
expect(schema.safeParse({ ...validRequest, content: '' }).success).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES + 1) })
|
||||
.success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({
|
||||
...validRequest,
|
||||
content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
ARTIFACT_CLI_MAX_RPC_BYTES,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../../../shared/artifacts'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
|
||||
const CloudOptions = {
|
||||
apiUrl: z.string().optional(),
|
||||
authToken: z.string().optional()
|
||||
apiUrl: z.string().max(2_048).optional(),
|
||||
authToken: z.string().max(16_384).optional()
|
||||
}
|
||||
|
||||
const ListOptions = z.object({
|
||||
@@ -11,26 +15,45 @@ const ListOptions = z.object({
|
||||
cursor: z.string().min(1).max(2_048).optional()
|
||||
})
|
||||
|
||||
const WriteRequest = z.object({
|
||||
sourceKey: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
contentType: z.enum(['text/html', 'text/markdown']),
|
||||
fileName: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
const SourceRequest = z.object({
|
||||
sourceKey: z.string().min(1).max(32_768),
|
||||
...CloudOptions
|
||||
})
|
||||
|
||||
const WriteRequest = z
|
||||
.object({
|
||||
sourceKey: z.string().min(1).max(32_768),
|
||||
content: z.string().min(1).max(ARTIFACT_CLI_MAX_RPC_BYTES),
|
||||
contentType: z.enum(['text/html', 'text/markdown']),
|
||||
fileName: z.string().min(1).max(512),
|
||||
title: z.string().max(512).optional(),
|
||||
...CloudOptions
|
||||
})
|
||||
.refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_CLI_MAX_RPC_BYTES, {
|
||||
message: 'Artifact request exceeds the local RPC size limit.'
|
||||
})
|
||||
|
||||
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'artifacts.list',
|
||||
params: ListOptions,
|
||||
handler: (params, { runtime }) => runtime.listArtifacts(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.getPublishedLink',
|
||||
params: SourceRequest,
|
||||
handler: (params, { runtime }) => runtime.getPublishedArtifactLink(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.share',
|
||||
params: WriteRequest,
|
||||
handler: (params, { runtime }) => runtime.shareArtifact(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.publish',
|
||||
params: WriteRequest,
|
||||
handler: (params, { runtime }) => runtime.publishArtifact(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.update',
|
||||
params: WriteRequest,
|
||||
@@ -38,7 +61,7 @@ export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'artifacts.unshare',
|
||||
params: z.object({ sourceKey: z.string().min(1), ...CloudOptions }),
|
||||
params: SourceRequest,
|
||||
handler: (params, { runtime }) => runtime.unshareArtifact(params)
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -135,8 +135,8 @@ describe('buildHostCliEnv', () => {
|
||||
const first = JSON.parse(String(build('host-a')))
|
||||
const second = JSON.parse(String(build('host-b')))
|
||||
expect(first.sourceKey).not.toBe(second.sourceKey)
|
||||
expect(first.sourceKey).toContain('host-a')
|
||||
expect(second.sourceKey).toContain('host-b')
|
||||
expect(JSON.parse(first.sourceKey)).toEqual(['ssh', 'host-a', '/srv/repo/report.html'])
|
||||
expect(JSON.parse(second.sourceKey)).toEqual(['ssh', 'host-b', '/srv/repo/report.html'])
|
||||
expect(first.fileName).toBe('report.html')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
import {
|
||||
REMOTE_ARTIFACT_INPUT_ENV,
|
||||
sshArtifactSourceKey,
|
||||
type RemoteArtifactInput
|
||||
} from '../../shared/artifact-cli-bridge'
|
||||
|
||||
@@ -178,7 +179,7 @@ export function buildHostCliEnv(args: {
|
||||
}
|
||||
if (args.artifactInput) {
|
||||
const sourceKey = args.runtimeAuthority
|
||||
? JSON.stringify(['ssh', args.runtimeAuthority.targetId, args.artifactInput.sourceKey])
|
||||
? sshArtifactSourceKey(args.runtimeAuthority.targetId, args.artifactInput.sourceKey)
|
||||
: args.artifactInput.sourceKey
|
||||
env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connect: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
getPublishedLink: vi.fn(),
|
||||
copyLink: vi.fn(),
|
||||
openLink: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
openPopover: null as ((open: boolean) => void) | null,
|
||||
state: {
|
||||
orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record<string, unknown>,
|
||||
orcaProfileConnecting: false,
|
||||
settings: { artifactSharingEnabled: true }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
...mocks.state,
|
||||
connectCurrentOrcaProfile: mocks.connect,
|
||||
openSettingsPage: mocks.openSettingsPage,
|
||||
openSettingsTarget: mocks.openSettingsTarget
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({
|
||||
children,
|
||||
onOpenChange
|
||||
}: {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
mocks.openPopover = onOpenChange ?? null
|
||||
return <>{children}</>
|
||||
},
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<span onClick={() => mocks.openPopover?.(true)}>{children}</span>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('./artifact-publish-flow', () => ({
|
||||
publishArtifactFromSurface: mocks.publish
|
||||
}))
|
||||
vi.mock('./artifact-published-link-client', () => ({
|
||||
getPublishedArtifactLink: mocks.getPublishedLink
|
||||
}))
|
||||
vi.mock('./artifact-link-actions', () => ({
|
||||
copyArtifactLink: mocks.copyLink,
|
||||
openArtifactInBrowser: mocks.openLink
|
||||
}))
|
||||
|
||||
import { ArtifactPublishButton } from './ArtifactPublishButton'
|
||||
|
||||
describe('ArtifactPublishButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.publish.mockResolvedValue({
|
||||
change: 'created',
|
||||
item: { shareUrl: 'https://example.com' }
|
||||
})
|
||||
mocks.getPublishedLink.mockResolvedValue(null)
|
||||
mocks.copyLink.mockResolvedValue(true)
|
||||
mocks.openPopover = null
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' }
|
||||
mocks.state.orcaProfileConnecting = false
|
||||
mocks.state.settings = { artifactSharingEnabled: true }
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('requires explicit confirmation before publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const createRequest = vi.fn()
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={createRequest} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Share public link' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(createRequest))
|
||||
expect(screen.getByText('https://example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Update shared content' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers sign-in and blocks confirmation while signed out', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Share public link' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes disabled publishing to Artifacts settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.settings = { artifactSharingEnabled: false }
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(await screen.findByRole('button', { name: 'Share public link' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Open Artifacts settings' }))
|
||||
|
||||
expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ pane: 'artifacts', repoId: null })
|
||||
expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
|
||||
expect(mocks.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the settings prompt once publishing is enabled', () => {
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText('Artifact sharing is off')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open Artifacts settings' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows and manages an existing public link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const createRequest = vi.fn()
|
||||
mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
|
||||
mocks.publish.mockResolvedValue({
|
||||
change: 'updated',
|
||||
item: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={createRequest} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(await screen.findByText('https://share.onorca.dev/a/artifact-a')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Copy link' }))
|
||||
expect(mocks.copyLink).toHaveBeenCalledWith('https://share.onorca.dev/a/artifact-a', {
|
||||
showSuccessToast: false
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Update shared content' }))
|
||||
await waitFor(() => expect(mocks.publish).toHaveBeenCalledWith(createRequest))
|
||||
})
|
||||
|
||||
it('keeps existing links available when publishing is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.state.settings = { artifactSharingEnabled: false }
|
||||
mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
|
||||
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
expect(await screen.findByRole('button', { name: 'Copy link' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Update shared content' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Open Artifacts settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('looks up a persisted link only after the popover opens', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />)
|
||||
|
||||
expect(mocks.getPublishedLink).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
|
||||
await waitFor(() => expect(mocks.getPublishedLink).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('does not start copy feedback after the panel unmounts', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finishCopy: ((copied: boolean) => void) | undefined
|
||||
mocks.getPublishedLink.mockResolvedValue('https://share.onorca.dev/a/artifact-a')
|
||||
mocks.copyLink.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
finishCopy = resolve
|
||||
})
|
||||
)
|
||||
const timeoutSpy = vi.spyOn(window, 'setTimeout')
|
||||
const view = render(
|
||||
<ArtifactPublishButton sourceKey="/repo/report.md" createRequest={vi.fn()} />
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Share as artifact' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy link' }))
|
||||
|
||||
view.unmount()
|
||||
finishCopy?.(true)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(timeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 1_500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowRight, Loader2, Share2 } from 'lucide-react'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ArtifactPublishedLinkPanel } from './ArtifactPublishedLinkPanel'
|
||||
import { getPublishedArtifactLink } from './artifact-published-link-client'
|
||||
import { publishArtifactFromSurface } from './artifact-publish-flow'
|
||||
|
||||
type PublishedLinkLookup = {
|
||||
key: string
|
||||
status: 'loading' | 'loaded' | 'error'
|
||||
shareUrl: string | null
|
||||
}
|
||||
|
||||
export function ArtifactPublishButton({
|
||||
sourceKey,
|
||||
createRequest,
|
||||
className,
|
||||
disabled
|
||||
}: {
|
||||
sourceKey: string
|
||||
createRequest: () => Promise<ArtifactWriteRequest>
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [lookupRevision, setLookupRevision] = useState(0)
|
||||
const [linkLookup, setLinkLookup] = useState<PublishedLinkLookup | null>(null)
|
||||
const lookupSequence = useRef(0)
|
||||
const popoverContentRef = useRef<HTMLDivElement>(null)
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const signedIn = authStatus?.state === 'connected'
|
||||
const sharingEnabled = settings?.artifactSharingEnabled === true
|
||||
const accountKey =
|
||||
authStatus?.state === 'connected'
|
||||
? JSON.stringify([
|
||||
authStatus.activeProfileId,
|
||||
authStatus.cloud?.userId ?? null,
|
||||
authStatus.cloud?.cloudProfileId ?? null,
|
||||
authStatus.cloud?.activeOrgId ?? null
|
||||
])
|
||||
: null
|
||||
const lookupKey = accountKey ? JSON.stringify([accountKey, sourceKey]) : null
|
||||
const currentLookup = linkLookup?.key === lookupKey ? linkLookup : null
|
||||
const checkingLink =
|
||||
signedIn && currentLookup?.status !== 'loaded' && currentLookup?.status !== 'error'
|
||||
const publishedLink = currentLookup?.status === 'loaded' ? currentLookup.shareUrl : null
|
||||
const busy = publishing || connecting
|
||||
const blocked = disabled || busy
|
||||
|
||||
useEffect(() => {
|
||||
const sequence = ++lookupSequence.current
|
||||
if (!open || !lookupKey) {
|
||||
setLinkLookup(null)
|
||||
return
|
||||
}
|
||||
setLinkLookup({ key: lookupKey, status: 'loading', shareUrl: null })
|
||||
void getPublishedArtifactLink(sourceKey)
|
||||
.then((shareUrl) => {
|
||||
if (lookupSequence.current === sequence) {
|
||||
setLinkLookup({ key: lookupKey, status: 'loaded', shareUrl })
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to check published artifact link:', error)
|
||||
if (lookupSequence.current === sequence) {
|
||||
setLinkLookup({ key: lookupKey, status: 'error', shareUrl: null })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
lookupSequence.current += 1
|
||||
}
|
||||
}, [lookupKey, lookupRevision, open, sourceKey])
|
||||
|
||||
const publish = async (): Promise<void> => {
|
||||
if (blocked || !signedIn || !sharingEnabled) {
|
||||
return
|
||||
}
|
||||
setPublishing(true)
|
||||
try {
|
||||
const result = await publishArtifactFromSurface(createRequest)
|
||||
if (result) {
|
||||
if (lookupKey) {
|
||||
setLinkLookup({ key: lookupKey, status: 'loaded', shareUrl: result.item.shareUrl })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openArtifactsSettings = (): void => {
|
||||
setOpen(false)
|
||||
openSettingsTarget({ pane: 'artifacts', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
const label = translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.a4a49da6af',
|
||||
'Share as artifact'
|
||||
)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(nextOpen) => !busy && setOpen(nextOpen)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('shrink-0', className)}
|
||||
disabled={blocked}
|
||||
aria-label={label}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
ref={popoverContentRef}
|
||||
tabIndex={-1}
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-80 p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
popoverContentRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1 border-b border-border/60 px-4 py-3.5">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.confirmTitle',
|
||||
'Share as artifact'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{publishedLink
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.publishedDescription',
|
||||
'Anyone with this link can view the shared file.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.confirmDescription',
|
||||
'This publishes the current file at a link anyone with the URL can view.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
{!signedIn ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-xs font-medium">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.accountTitle',
|
||||
'Orca account'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.accountDescription',
|
||||
'Sign in to create and manage this link.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={connecting || authStatus?.configured !== true}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
{connecting
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.signingIn',
|
||||
'Signing in…'
|
||||
)
|
||||
: authStatus?.state === 'reconnect-required'
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.signInAgain',
|
||||
'Sign in again'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.signIn',
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!sharingEnabled ? (
|
||||
<div className={cn('space-y-2', !signedIn && 'border-t border-border/60 pt-3')}>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.publishingOffTitle',
|
||||
'Artifact sharing is off'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.publishingOffDescription',
|
||||
'Learn about public links and enable sharing in Settings.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={openArtifactsSettings}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.openSettings',
|
||||
'Open Artifacts settings'
|
||||
)}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checkingLink ? (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.checkingLink',
|
||||
'Checking for an existing link…'
|
||||
)}
|
||||
</div>
|
||||
) : currentLookup?.status === 'error' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.checkFailed',
|
||||
'Could not check for an existing link.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setLookupRevision((current) => current + 1)}
|
||||
>
|
||||
{translate('auto.components.artifacts.ArtifactPublishButton.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
</div>
|
||||
) : publishedLink ? (
|
||||
<ArtifactPublishedLinkPanel
|
||||
key={publishedLink}
|
||||
shareUrl={publishedLink}
|
||||
publishing={publishing}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onUpdate={() => void publish()}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!signedIn || !sharingEnabled || busy}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Share2 />}
|
||||
{publishing
|
||||
? translate('auto.components.artifacts.ArtifactPublishButton.sharing', 'Sharing…')
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishButton.sharePublicLink',
|
||||
'Share public link'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Check, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { copyArtifactLink, openArtifactInBrowser } from './artifact-link-actions'
|
||||
|
||||
export function ArtifactPublishedLinkPanel({
|
||||
shareUrl,
|
||||
publishing,
|
||||
sharingEnabled,
|
||||
onUpdate
|
||||
}: {
|
||||
shareUrl: string
|
||||
publishing: boolean
|
||||
sharingEnabled: boolean
|
||||
onUpdate: () => void
|
||||
}): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copiedResetTimerRef = useRef<number | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
|
||||
const clearCopiedResetTimer = useCallback((): void => {
|
||||
if (copiedResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedResetTimerRef.current)
|
||||
copiedResetTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
const setPanelRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
mountedRef.current = node !== null
|
||||
if (!node) {
|
||||
clearCopiedResetTimer()
|
||||
}
|
||||
},
|
||||
[clearCopiedResetTimer]
|
||||
)
|
||||
|
||||
const copyLink = async (): Promise<void> => {
|
||||
if (!(await copyArtifactLink(shareUrl, { showSuccessToast: false }))) {
|
||||
return
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setCopied(true)
|
||||
clearCopiedResetTimer()
|
||||
copiedResetTimerRef.current = window.setTimeout(() => {
|
||||
copiedResetTimerRef.current = null
|
||||
setCopied(false)
|
||||
}, 1_500)
|
||||
}
|
||||
|
||||
const copyLabel = copied
|
||||
? translate('auto.components.artifacts.copySuccess', 'Artifact link copied')
|
||||
: translate('auto.components.artifacts.ArtifactPublishedLinkPanel.copyLink', 'Copy link')
|
||||
|
||||
return (
|
||||
<div ref={setPanelRef} className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<p
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
|
||||
title={shareUrl}
|
||||
>
|
||||
{shareUrl}
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void copyLink()}
|
||||
aria-label={copyLabel}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{copyLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openArtifactInBrowser(shareUrl)}
|
||||
aria-label={translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
|
||||
'Open link'
|
||||
)}
|
||||
>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.openLink',
|
||||
'Open link'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{sharingEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={publishing}
|
||||
onClick={onUpdate}
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{publishing
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.updating',
|
||||
'Updating…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactPublishedLinkPanel.update',
|
||||
'Update shared content'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,7 +198,9 @@ describe('ArtifactsPage', () => {
|
||||
const heading = await screen.findByText('No shared artifacts')
|
||||
expect(heading.parentElement).toHaveClass('flex-1', 'justify-center')
|
||||
expect(
|
||||
screen.getByText('Ask your agent to share an HTML or Markdown file, and it will appear here.')
|
||||
screen.getByText(
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText(/orca artifacts share/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
|
||||
@@ -328,11 +328,11 @@ export default function ArtifactsPage(): React.JSX.Element {
|
||||
: publishingBlocked
|
||||
? translate(
|
||||
'auto.components.artifacts.ArtifactsPage.publishingOffCopy',
|
||||
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.'
|
||||
'Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.artifacts.ArtifactsPage.emptyCopy',
|
||||
'Ask your agent to share an HTML or Markdown file, and it will appear here.'
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.'
|
||||
)}
|
||||
</p>
|
||||
{!nextCursor && publishingBlocked ? (
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export async function copyArtifactLink(shareUrl: string): Promise<void> {
|
||||
export async function copyArtifactLink(
|
||||
shareUrl: string,
|
||||
options: { showSuccessToast?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(shareUrl)
|
||||
toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
|
||||
if (options.showSuccessToast !== false) {
|
||||
toast.success(translate('auto.components.artifacts.copySuccess', 'Artifact link copied'))
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
toast.error(translate('auto.components.artifacts.copyFailed', 'Could not copy artifact link'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { publishArtifactFromSurface } from './artifact-publish-flow'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeRpc: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
state: {
|
||||
orcaProfileAuthStatus: { state: 'connected' } as { state: string } | null,
|
||||
connectCurrentOrcaProfile: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mocks.state }
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: mocks.toastError, success: mocks.toastSuccess }
|
||||
}))
|
||||
const request = {
|
||||
sourceKey: '/repo/report.html',
|
||||
content: '<h1>Report</h1>',
|
||||
contentType: 'text/html' as const,
|
||||
fileName: 'report.html'
|
||||
}
|
||||
const published = {
|
||||
change: 'created' as const,
|
||||
item: {
|
||||
artifact: { slug: 'artifact-a' },
|
||||
shareUrl: 'https://share.onorca.dev/a/artifact-a'
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.state.orcaProfileAuthStatus = { state: 'connected' }
|
||||
mocks.state.connectCurrentOrcaProfile = mocks.connect
|
||||
})
|
||||
|
||||
describe('artifact publish flow', () => {
|
||||
it('signs in before preparing and publishing the request', async () => {
|
||||
mocks.state.orcaProfileAuthStatus = { state: 'local' }
|
||||
mocks.connect.mockResolvedValue({ status: 'connected' })
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
|
||||
const createRequest = vi.fn().mockResolvedValue(request)
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
expect(createRequest).toHaveBeenCalledOnce()
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'artifacts.publish',
|
||||
request
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes with fresh content after reconnecting', async () => {
|
||||
mocks.connect.mockResolvedValue({ status: 'connected' })
|
||||
mocks.callRuntimeRpc
|
||||
.mockResolvedValueOnce({ status: 'reconnect-required' })
|
||||
.mockResolvedValueOnce({ status: 'ok', value: published })
|
||||
const createRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(request)
|
||||
.mockResolvedValueOnce({ ...request, content: '<h1>Fresh</h1>' })
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
expect(createRequest).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.callRuntimeRpc.mock.calls[1]?.[2]).toMatchObject({
|
||||
content: '<h1>Fresh</h1>'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces sign-in failures without preparing the file', async () => {
|
||||
mocks.state.orcaProfileAuthStatus = { state: 'local' }
|
||||
mocks.connect.mockRejectedValue(new Error('login failed'))
|
||||
const createRequest = vi.fn().mockResolvedValue(request)
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
|
||||
expect(createRequest).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', undefined)
|
||||
})
|
||||
|
||||
it('rejects an oversized request before RPC', async () => {
|
||||
const createRequest = vi.fn().mockResolvedValue({
|
||||
...request,
|
||||
content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
|
||||
})
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', {
|
||||
description: 'Artifacts shared from Orca must be smaller than 800 KB.'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation without putting the public link in the toast', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
|
||||
await publishArtifactFromSurface(() => Promise.resolve(request))
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('Artifact shared')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
ArtifactCloudOperation,
|
||||
ArtifactPublishResult,
|
||||
ArtifactWriteRequest
|
||||
} from '../../../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_CLI_MAX_RPC_BYTES,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../../../shared/artifacts'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const LOCAL_RUNTIME = { kind: 'local' } as const
|
||||
|
||||
export type ArtifactPublishPreparationErrorCode =
|
||||
| 'empty'
|
||||
| 'too-large'
|
||||
| 'unreadable'
|
||||
| 'unsupported'
|
||||
| 'binary'
|
||||
|
||||
export class ArtifactPublishPreparationError extends Error {
|
||||
constructor(readonly code: ArtifactPublishPreparationErrorCode) {
|
||||
super(code)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArtifactPublishRequest(
|
||||
request: ArtifactWriteRequest
|
||||
): ArtifactWriteRequest {
|
||||
if (!request.content) {
|
||||
throw new ArtifactPublishPreparationError('empty')
|
||||
}
|
||||
if (artifactWriteRequestByteLength(request) > ARTIFACT_CLI_MAX_RPC_BYTES) {
|
||||
throw new ArtifactPublishPreparationError('too-large')
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
export async function publishArtifactFromSurface(
|
||||
createRequest: () => Promise<ArtifactWriteRequest>
|
||||
): Promise<ArtifactPublishResult | null> {
|
||||
try {
|
||||
if (!(await ensureArtifactAccountConnected())) {
|
||||
return null
|
||||
}
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const request = validateArtifactPublishRequest(await createRequest())
|
||||
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactPublishResult>>(
|
||||
LOCAL_RUNTIME,
|
||||
'artifacts.publish',
|
||||
request
|
||||
)
|
||||
if (result.status === 'ok') {
|
||||
showArtifactPublishedToast(result.value)
|
||||
return result.value
|
||||
}
|
||||
if (result.status === 'unconfigured') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.9a078a0c65',
|
||||
'Artifact sharing is unavailable'
|
||||
),
|
||||
{ description: result.message }
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (attempt === 0 && (await reconnectArtifactAccount())) {
|
||||
continue
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.bba20daa6d',
|
||||
'Sign in to Orca and try again.'
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish artifact:', error)
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.54b1805328',
|
||||
'Could not share artifact'
|
||||
),
|
||||
error instanceof ArtifactPublishPreparationError
|
||||
? { description: artifactPreparationErrorDescription(error.code) }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function ensureArtifactAccountConnected(): Promise<boolean> {
|
||||
const state = useAppStore.getState()
|
||||
if (state.orcaProfileAuthStatus?.state === 'connected') {
|
||||
return true
|
||||
}
|
||||
return (await state.connectCurrentOrcaProfile())?.status === 'connected'
|
||||
}
|
||||
|
||||
async function reconnectArtifactAccount(): Promise<boolean> {
|
||||
return (await useAppStore.getState().connectCurrentOrcaProfile())?.status === 'connected'
|
||||
}
|
||||
|
||||
function showArtifactPublishedToast(result: ArtifactPublishResult): void {
|
||||
toast.success(
|
||||
result.change === 'created'
|
||||
? translate('auto.components.artifacts.artifact-publish-flow.430019efd0', 'Artifact shared')
|
||||
: translate('auto.components.artifacts.artifact-publish-flow.2fc727c831', 'Artifact updated')
|
||||
)
|
||||
}
|
||||
|
||||
function artifactPreparationErrorDescription(code: ArtifactPublishPreparationErrorCode): string {
|
||||
switch (code) {
|
||||
case 'empty':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.fbb5018602',
|
||||
'This file is empty.'
|
||||
)
|
||||
case 'too-large':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.6112db5a1c',
|
||||
'Artifacts shared from Orca must be smaller than 800 KB.'
|
||||
)
|
||||
case 'unreadable':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.e2ed5acd8c',
|
||||
"Orca couldn't read this file. Open it from a workspace and try again."
|
||||
)
|
||||
case 'unsupported':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.6d475e9b25',
|
||||
'Only local HTML and Markdown files can be shared as artifacts.'
|
||||
)
|
||||
case 'binary':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.29a406be09',
|
||||
'Artifacts must contain text.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getPublishedArtifactLink } from './artifact-published-link-client'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('published artifact link client', () => {
|
||||
it('returns the locally persisted public link', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: { shareUrl: 'https://share.onorca.dev/a/artifact-a' }
|
||||
})
|
||||
|
||||
await expect(getPublishedArtifactLink('/repo/report.md')).resolves.toBe(
|
||||
'https://share.onorca.dev/a/artifact-a'
|
||||
)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'artifacts.getPublishedLink',
|
||||
{ sourceKey: '/repo/report.md' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when the source has not been shared', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: null })
|
||||
await expect(getPublishedArtifactLink('/repo/report.md')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects when the account must reconnect', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'reconnect-required' })
|
||||
await expect(getPublishedArtifactLink('/repo/report.md')).rejects.toThrow('reconnect-required')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ArtifactCloudOperation, ArtifactPublishedLink } from '../../../../shared/artifacts'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
|
||||
const LOCAL_RUNTIME = { kind: 'local' } as const
|
||||
|
||||
export async function getPublishedArtifactLink(sourceKey: string): Promise<string | null> {
|
||||
const result = await callRuntimeRpc<ArtifactCloudOperation<ArtifactPublishedLink | null>>(
|
||||
LOCAL_RUNTIME,
|
||||
'artifacts.getPublishedLink',
|
||||
{ sourceKey }
|
||||
)
|
||||
if (result.status === 'ok') {
|
||||
return result.value?.shareUrl ?? null
|
||||
}
|
||||
throw new Error(result.status)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
|
||||
import { getWorkspaceFileBrowserOpenTarget } from '@/lib/file-preview'
|
||||
@@ -193,6 +193,12 @@ import { MarkupOverlay } from './markup/MarkupOverlay'
|
||||
import { MarkupDrawButton } from './markup/MarkupDrawButton'
|
||||
import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery'
|
||||
import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay'
|
||||
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
|
||||
import {
|
||||
browserFileUrlToAbsolutePath,
|
||||
getShareableBrowserArtifactFile,
|
||||
readBrowserHtmlArtifactRequest
|
||||
} from './browser-artifact-upload'
|
||||
import {
|
||||
BROWSER_GUEST_RECOVERY_ERROR_CODE,
|
||||
createBrowserPageGuestRecovery
|
||||
@@ -591,26 +597,8 @@ function isChromiumErrorPage(url: string): boolean {
|
||||
return url.startsWith('chrome-error://')
|
||||
}
|
||||
|
||||
function fileUrlToAbsolutePath(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
const hostPrefix =
|
||||
parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
|
||||
let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
|
||||
if (/^\/[A-Za-z]:\//.test(absolutePath)) {
|
||||
absolutePath = absolutePath.slice(1)
|
||||
}
|
||||
return absolutePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getNotebookPathFromBrowserUrl(url: string): string | null {
|
||||
const filePath = fileUrlToAbsolutePath(url)
|
||||
const filePath = browserFileUrlToAbsolutePath(url)
|
||||
return filePath?.toLowerCase().endsWith('.ipynb') ? filePath : null
|
||||
}
|
||||
|
||||
@@ -2517,6 +2505,7 @@ function BrowserPagePane({
|
||||
const handleInternalFileDragOverRef = useRef<(event: DragEvent<HTMLDivElement>) => void>(() => {})
|
||||
const handleInternalFileDropRef = useRef<(event: DragEvent<HTMLDivElement>) => void>(() => {})
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
const workspaceConnectionId = useAppStore((state) => getConnectionIdFromState(state, worktreeId))
|
||||
const browserDefaultZoomLevel = useAppStore(
|
||||
(state) => state.browserDefaultZoomLevel ?? DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
|
||||
)
|
||||
@@ -4569,6 +4558,8 @@ function BrowserPagePane({
|
||||
const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL
|
||||
const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url)
|
||||
const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url)
|
||||
const shareableArtifactFile =
|
||||
workspaceConnectionId === null ? getShareableBrowserArtifactFile(currentBrowserUrl) : null
|
||||
const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl
|
||||
const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl)
|
||||
const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab
|
||||
@@ -5042,6 +5033,14 @@ function BrowserPagePane({
|
||||
surfaceActive={isActive}
|
||||
/>
|
||||
|
||||
{shareableArtifactFile ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={shareableArtifactFile.filePath}
|
||||
className="h-7 w-7"
|
||||
createRequest={() => readBrowserHtmlArtifactRequest(currentBrowserUrl)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import type { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
|
||||
import {
|
||||
getShareableBrowserArtifactFile,
|
||||
readBrowserHtmlArtifactRequest
|
||||
} from './browser-artifact-upload'
|
||||
|
||||
const stat = vi.fn()
|
||||
const readFile = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.api = { fs: { stat, readFile } } as never
|
||||
stat.mockResolvedValue({ size: 20, isDirectory: false, mtime: 1 })
|
||||
readFile.mockResolvedValue({ content: '<h1>Mock</h1>', isBinary: false })
|
||||
})
|
||||
|
||||
describe('browser artifact upload', () => {
|
||||
it('recognizes local HTML URLs across path flavors', () => {
|
||||
expect(getShareableBrowserArtifactFile('file:///tmp/Design%20Review.html')).toEqual({
|
||||
fileName: 'Design Review.html',
|
||||
filePath: '/tmp/Design Review.html'
|
||||
})
|
||||
expect(getShareableBrowserArtifactFile('file:///C:/repo/report.HTM')).toEqual({
|
||||
fileName: 'report.HTM',
|
||||
filePath: 'C:\\repo\\report.HTM'
|
||||
})
|
||||
expect(getShareableBrowserArtifactFile('file://server/share/report.html')).toEqual({
|
||||
fileName: 'report.html',
|
||||
filePath: '\\\\server\\share\\report.html'
|
||||
})
|
||||
expect(getShareableBrowserArtifactFile('https://example.com/report.html')).toBeNull()
|
||||
expect(getShareableBrowserArtifactFile('file:///tmp/report.md')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the backing file through the authorized filesystem API', async () => {
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/report.html')).resolves.toEqual({
|
||||
sourceKey: '/tmp/report.html',
|
||||
content: '<h1>Mock</h1>',
|
||||
contentType: 'text/html',
|
||||
fileName: 'report.html'
|
||||
})
|
||||
expect(stat).toHaveBeenCalledWith({ filePath: '/tmp/report.html' })
|
||||
expect(readFile).toHaveBeenCalledWith({ filePath: '/tmp/report.html' })
|
||||
})
|
||||
|
||||
it('rejects oversized and unreadable files before upload', async () => {
|
||||
stat.mockResolvedValueOnce({
|
||||
size: ARTIFACT_CLI_MAX_RPC_BYTES + 1,
|
||||
isDirectory: false,
|
||||
mtime: 1
|
||||
})
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/large.html')).rejects.toMatchObject({
|
||||
code: 'too-large'
|
||||
} satisfies Partial<ArtifactPublishPreparationError>)
|
||||
expect(readFile).not.toHaveBeenCalled()
|
||||
|
||||
stat.mockRejectedValueOnce(new Error('access denied'))
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/private.html')).rejects.toMatchObject({
|
||||
code: 'unreadable'
|
||||
} satisfies Partial<ArtifactPublishPreparationError>)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { getRuntimePathBasename } from '../../../../shared/cross-platform-path'
|
||||
import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
|
||||
|
||||
export type ShareableBrowserArtifactFile = {
|
||||
fileName: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export function browserFileUrlToAbsolutePath(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
const hostPrefix =
|
||||
parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
|
||||
let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
|
||||
if (/^\/[A-Za-z]:\//.test(absolutePath)) {
|
||||
absolutePath = absolutePath.slice(1)
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(absolutePath) || absolutePath.startsWith('//')) {
|
||||
absolutePath = absolutePath.replaceAll('/', '\\')
|
||||
}
|
||||
return absolutePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getShareableBrowserArtifactFile(url: string): ShareableBrowserArtifactFile | null {
|
||||
const filePath = browserFileUrlToAbsolutePath(url)
|
||||
if (!filePath || !/\.html?$/i.test(filePath)) {
|
||||
return null
|
||||
}
|
||||
const fileName = getRuntimePathBasename(filePath)
|
||||
return fileName ? { fileName, filePath } : null
|
||||
}
|
||||
|
||||
export async function readBrowserHtmlArtifactRequest(url: string): Promise<ArtifactWriteRequest> {
|
||||
const file = getShareableBrowserArtifactFile(url)
|
||||
if (!file) {
|
||||
throw new ArtifactPublishPreparationError('unsupported')
|
||||
}
|
||||
try {
|
||||
const stat = await window.api.fs.stat({ filePath: file.filePath })
|
||||
if (stat.isDirectory) {
|
||||
throw new ArtifactPublishPreparationError('unsupported')
|
||||
}
|
||||
if (stat.size > ARTIFACT_CLI_MAX_RPC_BYTES) {
|
||||
throw new ArtifactPublishPreparationError('too-large')
|
||||
}
|
||||
const result = await window.api.fs.readFile({ filePath: file.filePath })
|
||||
if (result.isBinary) {
|
||||
throw new ArtifactPublishPreparationError('binary')
|
||||
}
|
||||
return {
|
||||
sourceKey: file.filePath,
|
||||
content: result.content,
|
||||
contentType: 'text/html',
|
||||
fileName: file.fileName
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactPublishPreparationError) {
|
||||
throw error
|
||||
}
|
||||
throw new ArtifactPublishPreparationError('unreadable')
|
||||
}
|
||||
}
|
||||
@@ -370,7 +370,7 @@ describe('AgentKanbanBoard', () => {
|
||||
renderBoard([card({ bucket: 'done' })])
|
||||
|
||||
expect(screen.getByText('完了')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('エージェントを検索')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Agent を検索')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^フィルター/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ const imeHarness = vi.hoisted(() => ({
|
||||
sendInput: (data: string) => void
|
||||
}[],
|
||||
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
|
||||
claimResult: false,
|
||||
inputSourceTrackerRequests: 0
|
||||
claimResult: false
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
@@ -131,12 +130,6 @@ vi.mock('@/components/terminal-pane/terminal-ime-composition-tracker', () => ({
|
||||
return tracker
|
||||
}
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({
|
||||
getMacNativeTextInputSourceTracker: () => {
|
||||
imeHarness.inputSourceTrackerRequests++
|
||||
return { getFeatures: () => ({}) }
|
||||
}
|
||||
}))
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState)
|
||||
useAppStore.getState = (): typeof storeState => storeState
|
||||
@@ -165,7 +158,6 @@ describe('AgentTerminalPreview', () => {
|
||||
imeHarness.forwarders.length = 0
|
||||
imeHarness.trackers.length = 0
|
||||
imeHarness.claimResult = false
|
||||
imeHarness.inputSourceTrackerRequests = 0
|
||||
emitData = null
|
||||
emitAppMenuPaste = null
|
||||
connect.mockResolvedValue({
|
||||
@@ -235,7 +227,6 @@ describe('AgentTerminalPreview', () => {
|
||||
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
|
||||
expect(imeHarness.forwarders).toHaveLength(1)
|
||||
expect(imeHarness.trackers).toHaveLength(1)
|
||||
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
|
||||
|
||||
imeHarness.forwarders[0]!.sendInput('。')
|
||||
expect(terminal.input).toHaveBeenCalledOnce()
|
||||
@@ -260,7 +251,6 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(copied).toBe(false)
|
||||
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
|
||||
})
|
||||
|
||||
it('does not install the IME native-text forwarder off macOS', async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Terminal } from '@xterm/xterm'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { installTerminalImeCompositionTracker } from '@/components/terminal-pane/terminal-ime-composition-tracker'
|
||||
import { installTerminalImeNativeTextForwarder } from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
|
||||
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
|
||||
|
||||
export type PreviewImeBridge = {
|
||||
/** True when the forwarder owns this keydown, so xterm must not encode it. */
|
||||
@@ -21,14 +20,11 @@ export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge |
|
||||
if (getShortcutPlatform() !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
// Why: prewarm the async input-source lookup before the first native-text key needs classification.
|
||||
const inputSourceTracker = getMacNativeTextInputSourceTracker()
|
||||
const compositionTracker = installTerminalImeCompositionTracker(terminal.element)
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => compositionTracker?.isActive() ?? false,
|
||||
sendInput: (data) => terminal.input(data),
|
||||
getInputSourceFeatures: () => inputSourceTracker.getFeatures()
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
return {
|
||||
claimKeyEvent: (event) => forwarder?.claimKeyEvent(event) ?? false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './editor-panel-git-entry-selector'
|
||||
import { createEditorPanelDraftSelector } from './editor-panel-draft-selector'
|
||||
import { attemptEditorFileSave } from './editor-file-save-attempt'
|
||||
import { createCurrentMarkdownArtifactRequest } from './markdown-artifact-upload'
|
||||
|
||||
function EditorPanelInner({
|
||||
activeFileId: activeFileIdProp,
|
||||
@@ -348,6 +349,14 @@ function EditorPanelInner({
|
||||
markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true
|
||||
const isMarkdownTableOfContentsVisible =
|
||||
markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false
|
||||
const createActiveMarkdownArtifactRequest = () =>
|
||||
Promise.resolve(
|
||||
createCurrentMarkdownArtifactRequest(
|
||||
activeFile,
|
||||
markdownDocumentStateFileId,
|
||||
activeMarkdownContent ?? ''
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
// Why: each split pane needs an isolated bridge between its diff editor and header controls.
|
||||
@@ -389,6 +398,9 @@ function EditorPanelInner({
|
||||
onExportMarkdownToPdf={() =>
|
||||
void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
|
||||
}
|
||||
createMarkdownArtifactRequest={
|
||||
activeMarkdownContent === null ? undefined : createActiveMarkdownArtifactRequest
|
||||
}
|
||||
onContentChange={handleContentChange}
|
||||
onContentChangeForFile={handleContentChangeForFile}
|
||||
onDirtyStateHint={handleDirtyStateHint}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { EditorPanelHeader } from './EditorPanelHeader'
|
||||
@@ -41,6 +42,10 @@ vi.mock('./EditorPanelMarkdownActionsMenu', () => ({
|
||||
EditorPanelMarkdownActionsMenu: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/artifacts/ArtifactPublishButton', () => ({
|
||||
ArtifactPublishButton: () => <button data-artifact-publish />
|
||||
}))
|
||||
|
||||
vi.mock('./diff-navigation-context', () => ({
|
||||
useDiffNavigation: () => ({
|
||||
changeCount: 2,
|
||||
@@ -59,43 +64,47 @@ const activeFile: OpenFile = {
|
||||
mode: 'diff'
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
activeFile,
|
||||
copiedPathVisible: false,
|
||||
isSingleDiff: false,
|
||||
isDiffSurface: true,
|
||||
isMarkdown: false,
|
||||
isCsv: false,
|
||||
isNotebook: false,
|
||||
hasEditorToggle: false,
|
||||
availableEditorToggleModes: [],
|
||||
effectiveToggleValue: 'edit',
|
||||
canOpenPreviewToSide: false,
|
||||
canShowMarkdownPreview: false,
|
||||
canShowMarkdownTableOfContents: false,
|
||||
isMarkdownTableOfContentsDisabled: false,
|
||||
shouldShowMarkdownExportAction: false,
|
||||
canExportMarkdownToPdf: false,
|
||||
showMarkdownTableOfContents: false,
|
||||
canShowMarkdownFrontmatterToggle: false,
|
||||
markdownFrontmatterVisible: false,
|
||||
sideBySide: false,
|
||||
openFileState: { canOpen: false },
|
||||
onCopyPath: vi.fn(),
|
||||
onOpenDiffTargetFile: vi.fn(),
|
||||
onOpenPreviewToSide: vi.fn(),
|
||||
onOpenMarkdownPreview: vi.fn(),
|
||||
onOpenContainingFolder: vi.fn(),
|
||||
onToggleSideBySide: vi.fn(),
|
||||
onEditorToggleChange: vi.fn(),
|
||||
onToggleMarkdownTableOfContents: vi.fn(),
|
||||
onToggleMarkdownFrontmatter: vi.fn(),
|
||||
onExportMarkdownToPdf: vi.fn()
|
||||
} satisfies ComponentProps<typeof EditorPanelHeader>
|
||||
|
||||
function renderHeader(overrides: Partial<ComponentProps<typeof EditorPanelHeader>> = {}): string {
|
||||
return renderToStaticMarkup(<EditorPanelHeader {...baseProps} {...overrides} />)
|
||||
}
|
||||
|
||||
describe('EditorPanelHeader', () => {
|
||||
it('shares one tooltip provider across the diff header controls', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EditorPanelHeader
|
||||
activeFile={activeFile}
|
||||
copiedPathVisible={false}
|
||||
isSingleDiff={false}
|
||||
isDiffSurface
|
||||
isMarkdown={false}
|
||||
isCsv={false}
|
||||
isNotebook={false}
|
||||
hasEditorToggle={false}
|
||||
availableEditorToggleModes={[]}
|
||||
effectiveToggleValue="edit"
|
||||
canOpenPreviewToSide={false}
|
||||
canShowMarkdownPreview={false}
|
||||
canShowMarkdownTableOfContents={false}
|
||||
isMarkdownTableOfContentsDisabled={false}
|
||||
shouldShowMarkdownExportAction={false}
|
||||
canExportMarkdownToPdf={false}
|
||||
showMarkdownTableOfContents={false}
|
||||
canShowMarkdownFrontmatterToggle={false}
|
||||
markdownFrontmatterVisible={false}
|
||||
sideBySide={false}
|
||||
openFileState={{ canOpen: false }}
|
||||
onCopyPath={vi.fn()}
|
||||
onOpenDiffTargetFile={vi.fn()}
|
||||
onOpenPreviewToSide={vi.fn()}
|
||||
onOpenMarkdownPreview={vi.fn()}
|
||||
onOpenContainingFolder={vi.fn()}
|
||||
onToggleSideBySide={vi.fn()}
|
||||
onEditorToggleChange={vi.fn()}
|
||||
onToggleMarkdownTableOfContents={vi.fn()}
|
||||
onToggleMarkdownFrontmatter={vi.fn()}
|
||||
onExportMarkdownToPdf={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const html = renderHeader()
|
||||
|
||||
expect(html.match(/data-tooltip-provider/g)).toHaveLength(1)
|
||||
expect(html.match(/data-tooltip="true"/g)).toHaveLength(3)
|
||||
@@ -103,4 +112,30 @@ describe('EditorPanelHeader', () => {
|
||||
expect(html).toContain('aria-label="Previous change"')
|
||||
expect(html).toContain('aria-label="Next change"')
|
||||
})
|
||||
|
||||
it('offers artifact sharing only on non-diff Markdown surfaces', () => {
|
||||
const createRequest = vi.fn()
|
||||
|
||||
expect(
|
||||
renderHeader({
|
||||
isDiffSurface: false,
|
||||
isMarkdown: true,
|
||||
createMarkdownArtifactRequest: createRequest
|
||||
})
|
||||
).toContain('data-artifact-publish="true"')
|
||||
expect(
|
||||
renderHeader({
|
||||
isDiffSurface: true,
|
||||
isMarkdown: true,
|
||||
createMarkdownArtifactRequest: createRequest
|
||||
})
|
||||
).not.toContain('data-artifact-publish')
|
||||
expect(
|
||||
renderHeader({
|
||||
isDiffSurface: false,
|
||||
isMarkdown: false,
|
||||
createMarkdownArtifactRequest: createRequest
|
||||
})
|
||||
).not.toContain('data-artifact-publish')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@ import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
|
||||
import { useDiffNavigation } from './diff-navigation-context'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { ArtifactPublishButton } from '@/components/artifacts/ArtifactPublishButton'
|
||||
import { markdownArtifactSourceKey } from './markdown-artifact-upload'
|
||||
|
||||
type EditorPanelHeaderProps = {
|
||||
activeFile: OpenFile
|
||||
@@ -50,6 +53,7 @@ type EditorPanelHeaderProps = {
|
||||
onToggleMarkdownTableOfContents: () => void
|
||||
onToggleMarkdownFrontmatter: () => void
|
||||
onExportMarkdownToPdf: () => void
|
||||
createMarkdownArtifactRequest?: () => Promise<ArtifactWriteRequest>
|
||||
}
|
||||
|
||||
export function EditorPanelHeader({
|
||||
@@ -83,7 +87,8 @@ export function EditorPanelHeader({
|
||||
onEditorToggleChange,
|
||||
onToggleMarkdownTableOfContents,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf
|
||||
onExportMarkdownToPdf,
|
||||
createMarkdownArtifactRequest
|
||||
}: EditorPanelHeaderProps): React.JSX.Element {
|
||||
const diffComments = useAppStore((s) =>
|
||||
selectWorktreeDiffCommentsOrEmpty(s, activeFile.worktreeId)
|
||||
@@ -311,6 +316,13 @@ export function EditorPanelHeader({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isMarkdown && !isDiffSurface && createMarkdownArtifactRequest ? (
|
||||
<ArtifactPublishButton
|
||||
sourceKey={markdownArtifactSourceKey(activeFile)}
|
||||
className="size-6 [&_svg]:size-3.5!"
|
||||
createRequest={createMarkdownArtifactRequest}
|
||||
/>
|
||||
) : null}
|
||||
<EditorPanelMarkdownActionsMenu
|
||||
isMarkdown={isMarkdown}
|
||||
isDiffSurface={isDiffSurface}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { DiffContent, FileContent } from './editor-panel-content-types'
|
||||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import { getUntitledFileRoot } from './untitled-file-rename-path'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
|
||||
type EditorPanelRenderModel = ReturnType<typeof getEditorPanelRenderModel>
|
||||
|
||||
@@ -41,6 +42,7 @@ type EditorPanelShellProps = {
|
||||
onToggleMarkdownTableOfContents: () => void
|
||||
onToggleMarkdownFrontmatter: () => void
|
||||
onExportMarkdownToPdf: () => void
|
||||
createMarkdownArtifactRequest?: () => Promise<ArtifactWriteRequest>
|
||||
onContentChange: (content: string) => void
|
||||
onContentChangeForFile: (file: OpenFile, content: string) => void
|
||||
onDirtyStateHint: (dirty: boolean) => void
|
||||
@@ -81,6 +83,7 @@ export function EditorPanelShell({
|
||||
onToggleMarkdownTableOfContents,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf,
|
||||
createMarkdownArtifactRequest,
|
||||
onContentChange,
|
||||
onContentChangeForFile,
|
||||
onDirtyStateHint,
|
||||
@@ -127,6 +130,7 @@ export function EditorPanelShell({
|
||||
onToggleMarkdownTableOfContents={onToggleMarkdownTableOfContents}
|
||||
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
|
||||
onExportMarkdownToPdf={onExportMarkdownToPdf}
|
||||
createMarkdownArtifactRequest={createMarkdownArtifactRequest}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<EditorLoadingFallback />}>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
createCurrentMarkdownArtifactRequest,
|
||||
createMarkdownArtifactRequest,
|
||||
markdownArtifactSourceKey
|
||||
} from './markdown-artifact-upload'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
drafts: {} as Record<string, string>,
|
||||
flush: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => ({ editorDrafts: mocks.drafts }) }
|
||||
}))
|
||||
vi.mock('./editor-pending-flush', () => ({
|
||||
flushPendingEditorChange: mocks.flush
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.drafts = {}
|
||||
mocks.flush.mockReset()
|
||||
})
|
||||
|
||||
function openFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: '/repo/notes.md',
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'worktree-1',
|
||||
language: 'markdown',
|
||||
isDirty: false,
|
||||
mode: 'edit',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('Markdown artifact upload', () => {
|
||||
it('uses the ordinary file path for local and folder workspaces', () => {
|
||||
expect(markdownArtifactSourceKey(openFile())).toBe('/repo/notes.md')
|
||||
expect(createMarkdownArtifactRequest(openFile(), '# Draft')).toEqual({
|
||||
sourceKey: '/repo/notes.md',
|
||||
content: '# Draft',
|
||||
contentType: 'text/markdown',
|
||||
fileName: 'notes.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates source identity by runtime owner', () => {
|
||||
const file = openFile({
|
||||
runtimeEnvironmentId: 'server-1',
|
||||
operationProvenance: {
|
||||
ownershipProjection: 'explicit',
|
||||
generation: {
|
||||
route: {
|
||||
runtimeEnvironmentId: 'server-1',
|
||||
executionHostId: 'ssh:build-box'
|
||||
},
|
||||
runtimeConnectionGeneration: 1,
|
||||
runtimePairingRevision: 1,
|
||||
runtimeSshGeneration: 1,
|
||||
nestedSshGeneration: 1,
|
||||
directSshGeneration: null
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(JSON.parse(markdownArtifactSourceKey(file))).toEqual([
|
||||
'ssh',
|
||||
'build-box',
|
||||
'/repo/notes.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('matches the SSH CLI source identity for external files', () => {
|
||||
expect(
|
||||
JSON.parse(markdownArtifactSourceKey(openFile({ externalSshTargetId: 'build-box' })))
|
||||
).toEqual(['ssh', 'build-box', '/repo/notes.md'])
|
||||
})
|
||||
|
||||
it('flushes and reads the latest unsaved editor buffer', () => {
|
||||
mocks.flush.mockImplementation((fileId: string) => {
|
||||
mocks.drafts[fileId] = '# Latest edit'
|
||||
})
|
||||
|
||||
expect(
|
||||
createCurrentMarkdownArtifactRequest(openFile(), '/repo/notes.md', '# Stale content').content
|
||||
).toBe('# Latest edit')
|
||||
expect(mocks.flush).toHaveBeenCalledWith('/repo/notes.md')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ArtifactWriteRequest } from '../../../../shared/artifacts'
|
||||
import { sshArtifactSourceKey } from '../../../../shared/artifact-cli-bridge'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { basename } from '@/lib/path'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { flushPendingEditorChange } from './editor-pending-flush'
|
||||
|
||||
export function markdownArtifactSourceKey(file: OpenFile): string {
|
||||
const route = file.operationProvenance?.generation.route
|
||||
const host = parseExecutionHostId(route?.executionHostId)
|
||||
if (host?.kind === 'ssh') {
|
||||
return sshArtifactSourceKey(host.targetId, file.filePath)
|
||||
}
|
||||
if (file.externalSshTargetId) {
|
||||
return sshArtifactSourceKey(file.externalSshTargetId, file.filePath)
|
||||
}
|
||||
if (route && (route.runtimeEnvironmentId || route.executionHostId !== 'local')) {
|
||||
return JSON.stringify([
|
||||
'editor',
|
||||
route.runtimeEnvironmentId ?? null,
|
||||
route.executionHostId,
|
||||
file.filePath
|
||||
])
|
||||
}
|
||||
if (file.runtimeEnvironmentId) {
|
||||
return JSON.stringify(['editor', file.runtimeEnvironmentId ?? null, 'remote', file.filePath])
|
||||
}
|
||||
return file.filePath
|
||||
}
|
||||
|
||||
export function createMarkdownArtifactRequest(
|
||||
file: OpenFile,
|
||||
content: string
|
||||
): ArtifactWriteRequest {
|
||||
return {
|
||||
sourceKey: markdownArtifactSourceKey(file),
|
||||
content,
|
||||
contentType: 'text/markdown',
|
||||
fileName: basename(file.filePath)
|
||||
}
|
||||
}
|
||||
|
||||
export function createCurrentMarkdownArtifactRequest(
|
||||
file: OpenFile,
|
||||
contentFileId: string,
|
||||
fallbackContent: string
|
||||
): ArtifactWriteRequest {
|
||||
flushPendingEditorChange(contentFileId)
|
||||
const content = useAppStore.getState().editorDrafts[contentFileId] ?? fallbackContent
|
||||
return createMarkdownArtifactRequest(file, content)
|
||||
}
|
||||
@@ -61,13 +61,15 @@ describe('ArtifactsSettingsPane', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('How to use Artifacts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose a file to share')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('For example: “Share this HTML mock as an artifact.”')
|
||||
screen.getByText(
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Share the public link')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy the public link')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Your agent returns a link that anyone with the URL can view.')
|
||||
screen.getByText('After publishing, copy the link and send it to your team.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Manage it in Orca')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -137,15 +139,15 @@ describe('ArtifactsSettingsPane', () => {
|
||||
expect(mocks.openArtifactsPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the publish capability off by default and describes it as device-wide', () => {
|
||||
it('describes public publishing and existing-link retention', () => {
|
||||
render(<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow publishing public artifact links' })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText(/your agents and the orca CLI/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/mint links anyone with the URL can open/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/does not delete existing links/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Publish HTML and Markdown files as links/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Existing links remain until you delete them/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Off by default/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('grants and revokes the publish capability through the toggle', async () => {
|
||||
@@ -198,9 +200,11 @@ describe('ArtifactsSettingsPane', () => {
|
||||
<ArtifactsSettingsPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Allow publishing first')).toBeInTheDocument()
|
||||
expect(screen.getByText(/artifact_sharing_disabled/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Publishing is off, so nothing on this device/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Enable artifact sharing')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Turn on “Allow publishing public artifact links” above.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/Enable artifact sharing above/)).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<ArtifactsSettingsPane
|
||||
@@ -208,8 +212,8 @@ describe('ArtifactsSettingsPane', () => {
|
||||
updateSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Allow publishing first')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Ask your agent to share it')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Enable artifact sharing')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Choose a file to share')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('points web clients at the desktop app for the opt-in step', () => {
|
||||
|
||||
@@ -41,16 +41,16 @@ export function ArtifactsSettingsPane({
|
||||
key: 'enable',
|
||||
title: translate(
|
||||
'auto.components.settings.artifacts.enableStepTitle',
|
||||
'Allow publishing first'
|
||||
'Enable artifact sharing'
|
||||
),
|
||||
description: isWebClient
|
||||
? translate(
|
||||
'auto.components.settings.artifacts.enableStepWebDescription',
|
||||
'Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.'
|
||||
'Open Settings → Artifacts in the Orca desktop app on the host device and enable publishing.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.artifacts.enableStepDescription',
|
||||
'Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.'
|
||||
'Turn on “Allow publishing public artifact links” above.'
|
||||
)
|
||||
}
|
||||
]),
|
||||
@@ -58,19 +58,19 @@ export function ArtifactsSettingsPane({
|
||||
key: 'share',
|
||||
title: translate(
|
||||
'auto.components.settings.artifacts.shareStepTitle',
|
||||
'Ask your agent to share it'
|
||||
'Choose a file to share'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.shareStepDescription',
|
||||
'For example: “Share this HTML mock as an artifact.”'
|
||||
'Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.'
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
title: translate('auto.components.settings.artifacts.linkStepTitle', 'Share the public link'),
|
||||
title: translate('auto.components.settings.artifacts.linkStepTitle', 'Copy the public link'),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.linkStepDescription',
|
||||
'Your agent returns a link that anyone with the URL can view.'
|
||||
'After publishing, copy the link and send it to your team.'
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -78,7 +78,7 @@ export function ArtifactsSettingsPane({
|
||||
title: translate('auto.components.settings.artifacts.manageStepTitle', 'Manage it in Orca'),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.manageStepDescription',
|
||||
'Open Artifacts from the sidebar to revisit or delete links owned by your account.'
|
||||
'Open Artifacts from the sidebar to preview or remove links.'
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -94,11 +94,11 @@ export function ArtifactsSettingsPane({
|
||||
isWebClient
|
||||
? translate(
|
||||
'auto.components.settings.artifacts.allowPublishingWebDescription',
|
||||
'Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.'
|
||||
'Desktop only. Open Settings → Artifacts on the host device to change this setting.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.artifacts.allowPublishingDescription',
|
||||
'Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.'
|
||||
'Publish HTML and Markdown files as links anyone with the URL can open. Existing links remain until you delete them from Artifacts.'
|
||||
)
|
||||
}
|
||||
checked={sharingEnabled}
|
||||
@@ -153,11 +153,11 @@ export function ArtifactsSettingsPane({
|
||||
{sharingEnabled
|
||||
? translate(
|
||||
'auto.components.settings.artifacts.howToDescription',
|
||||
'Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.'
|
||||
'Publish HTML or Markdown files as public links, then share them with your team.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.artifacts.howToDescriptionDisabled',
|
||||
'Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.'
|
||||
'Enable artifact sharing above to publish HTML or Markdown files as public links.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,11 @@ describe('OrcaAccountSettingsPane', () => {
|
||||
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
|
||||
render(<OrcaAccountSettingsPane />)
|
||||
|
||||
expect(screen.getByText('Sign in to use Artifacts and Orca Relay.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
|
||||
expect(mocks.connect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ function accountStatusCopy(
|
||||
if (state === 'local') {
|
||||
return translate(
|
||||
'auto.components.settings.orcaAccount.signedOut',
|
||||
'Sign in to use Artifacts and Orca Relay.'
|
||||
'Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.'
|
||||
)
|
||||
}
|
||||
return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…')
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getArtifactsSettingsSearchEntries = createLocalizedCatalog(() => [
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.artifacts.allowPublishingSearchDescription',
|
||||
'Let agents and the orca CLI upload files to your Orca account and mint public links.'
|
||||
'Allow Orca to publish HTML and Markdown files as public links.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.artifacts.keywordArtifacts', 'artifacts'),
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"recordedFrom": "issue #11170, probe page in Chromium on macOS (reporter's hardware, Korean input source active)",
|
||||
"platform": "macos",
|
||||
"note": "Exactly one case is recorded: `remapped`, the reporter's verbatim capture, where keydown and keypress both still carry the layout character and only charCode and the input event's data carry the substituted one. Every other case is DERIVED from it by changing one thing - the layout character, or the substitution's presence - and is marked `recorded: false` with its own note saying what was changed and why. Do not read the four derived cases as independent captures. No beforeinput record exists in the capture; none is invented here.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "remapped",
|
||||
"note": "Verbatim from the issue. ~/Library/KeyBindings/DefaultKeyBinding.dict maps the character the Korean layout puts on Backquote to a backtick. keydown.key is the raw layout character; charCode 96 and input.data carry the OS substitution.",
|
||||
"recorded": true,
|
||||
"expectedPty": "`",
|
||||
"dom": [
|
||||
{
|
||||
"type": "keydown",
|
||||
"key": "₩",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "keypress",
|
||||
"key": "₩",
|
||||
"code": "Backquote",
|
||||
"keyCode": 96,
|
||||
"charCode": 96,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"data": "`",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"value": "`"
|
||||
},
|
||||
{
|
||||
"type": "keyup",
|
||||
"key": "₩",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": "`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "remapped-other-korean-layout",
|
||||
"note": "DERIVED, not separately recorded: the `remapped` capture with the layout character changed to the one 세벌식 최종 puts on the same physical key. Korean layouts disagree about that position - 두벌식 and 세벌식 390 give U+20A9, 세벌식 최종 gives an asterisk - so one dict entry has to survive either. Honouring the substitution by listing characters can only cover the ones someone remembered to list.",
|
||||
"recorded": false,
|
||||
"expectedPty": "`",
|
||||
"dom": [
|
||||
{
|
||||
"type": "keydown",
|
||||
"key": "*",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "keypress",
|
||||
"key": "*",
|
||||
"code": "Backquote",
|
||||
"keyCode": 96,
|
||||
"charCode": 96,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"data": "`",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"value": "`"
|
||||
},
|
||||
{
|
||||
"type": "keyup",
|
||||
"key": "*",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": "`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "no-remap-korean",
|
||||
"note": "DERIVED, not separately recorded: the `remapped` capture with the dict substitution removed, so the layout's own character is what the text system commits. charCode and input.data become U+20A9 to match keydown.key. This is the paired negative - the same physical key with no remap in play must still deliver the layout character.",
|
||||
"recorded": false,
|
||||
"expectedPty": "₩",
|
||||
"dom": [
|
||||
{
|
||||
"type": "keydown",
|
||||
"key": "₩",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "keypress",
|
||||
"key": "₩",
|
||||
"code": "Backquote",
|
||||
"keyCode": 8361,
|
||||
"charCode": 8361,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"data": "₩",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"value": "₩"
|
||||
},
|
||||
{
|
||||
"type": "keyup",
|
||||
"key": "₩",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": "₩"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "no-remap-ascii",
|
||||
"note": "DERIVED, not separately recorded. The issue states that with ABC selected keydown.key is already the backtick and nothing is wrong; this is that sequence. Guards the case where a fix rewrites the Backquote position unconditionally.",
|
||||
"recorded": false,
|
||||
"expectedPty": "`",
|
||||
"dom": [
|
||||
{
|
||||
"type": "keydown",
|
||||
"key": "`",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "keypress",
|
||||
"key": "`",
|
||||
"code": "Backquote",
|
||||
"keyCode": 96,
|
||||
"charCode": 96,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"data": "`",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"value": "`"
|
||||
},
|
||||
{
|
||||
"type": "keyup",
|
||||
"key": "`",
|
||||
"code": "Backquote",
|
||||
"keyCode": 192,
|
||||
"isComposing": false,
|
||||
"value": "`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "remapped-neighbour-key",
|
||||
"note": "DERIVED, not separately recorded. An ordinary letter pressed straight after the remapped key, to catch a fix that leaves the claim armed and swallows or duplicates the next keystroke.",
|
||||
"recorded": false,
|
||||
"expectedPty": "a",
|
||||
"dom": [
|
||||
{
|
||||
"type": "keydown",
|
||||
"key": "a",
|
||||
"code": "KeyA",
|
||||
"keyCode": 65,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "keypress",
|
||||
"key": "a",
|
||||
"code": "KeyA",
|
||||
"keyCode": 97,
|
||||
"charCode": 97,
|
||||
"isComposing": false,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"data": "a",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"value": "a"
|
||||
},
|
||||
{
|
||||
"type": "keyup",
|
||||
"key": "a",
|
||||
"code": "KeyA",
|
||||
"keyCode": 65,
|
||||
"isComposing": false,
|
||||
"value": "a"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+2159
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
_resetMacNativeTextInputSourceTrackerForTests,
|
||||
createMacNativeTextInputSourceTracker,
|
||||
getMacNativeTextInputSourceFeatures,
|
||||
getMacNativeTextInputSourceTracker
|
||||
} from './terminal-ime-input-source'
|
||||
|
||||
describe('getMacNativeTextInputSourceFeatures', () => {
|
||||
it('enables punctuation forwarding for Apple Chinese, Japanese and Korean input methods', () => {
|
||||
for (const sourceId of [
|
||||
'com.apple.inputmethod.SCIM.ITABC',
|
||||
'com.apple.inputmethod.TCIM.Pinyin',
|
||||
'com.apple.keylayout.PinyinKeyboard',
|
||||
'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese',
|
||||
'com.apple.inputmethod.Korean.2SetKorean'
|
||||
]) {
|
||||
expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({
|
||||
forwardHangulJamo: sourceId.includes('Korean'),
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts common third-party CJK input source IDs', () => {
|
||||
expect(getMacNativeTextInputSourceFeatures('com.google.inputmethod.Japanese.base')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
expect(getMacNativeTextInputSourceFeatures('com.sogou.inputmethod.sogou.pinyin')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
expect(getMacNativeTextInputSourceFeatures('com.bytedance.inputmethod.Doubao')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
expect(getMacNativeTextInputSourceFeatures('im.rime.inputmethod.Squirrel.Rime')).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
})
|
||||
|
||||
it('enables short native replacement forwarding for Vietnamese input methods', () => {
|
||||
for (const sourceId of [
|
||||
'com.apple.inputmethod.Vietnamese',
|
||||
'com.apple.inputmethod.Vietnamese.Telex',
|
||||
'com.apple.inputmethod.Vietnamese.VNI',
|
||||
'org.unikey.inputmethod.Unikey'
|
||||
]) {
|
||||
expect(getMacNativeTextInputSourceFeatures(sourceId)).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects plain keyboard layouts and unrelated input methods', () => {
|
||||
const disabled = {
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
}
|
||||
expect(getMacNativeTextInputSourceFeatures(null)).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.US')).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.ABC')).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.keylayout.PolishPro')).toEqual(disabled)
|
||||
expect(getMacNativeTextInputSourceFeatures('com.apple.inputmethod.CharacterPaletteIM')).toEqual(
|
||||
disabled
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMacNativeTextInputSourceTracker', () => {
|
||||
beforeEach(() => {
|
||||
_resetMacNativeTextInputSourceTrackerForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetMacNativeTextInputSourceTrackerForTests()
|
||||
})
|
||||
|
||||
it('starts disabled and refreshes from the current input source', async () => {
|
||||
let sourceId = 'com.apple.keylayout.US'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
||||
sourceId = 'com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese'
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
|
||||
sourceId = 'com.apple.inputmethod.Vietnamese.Telex'
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
})
|
||||
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('refreshes on window focus so language switches are picked up', async () => {
|
||||
let sourceId = 'com.apple.keylayout.US'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
await tracker.refresh()
|
||||
|
||||
sourceId = 'com.apple.inputmethod.TCIM.Pinyin'
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
|
||||
await vi.waitFor(() => expect(tracker.isActive()).toBe(true))
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('refreshes on keydown so focused input source switches are picked up', async () => {
|
||||
let sourceId = 'com.apple.keylayout.ABC'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
|
||||
sourceId = 'com.apple.inputmethod.SCIM.ITABC'
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(tracker.getFeatures()).toEqual({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
})
|
||||
)
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('throttles ordinary key refreshes while input-source features are disabled', async () => {
|
||||
let sourceId = 'com.apple.keylayout.ABC'
|
||||
const readInputSourceId = vi.fn(async () => sourceId)
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, { readInputSourceId })
|
||||
await tracker.refresh()
|
||||
readInputSourceId.mockClear()
|
||||
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1000)
|
||||
try {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
||||
await vi.waitFor(() => expect(readInputSourceId).toHaveBeenCalledTimes(1))
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' }))
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'b' }))
|
||||
await Promise.resolve()
|
||||
expect(readInputSourceId).toHaveBeenCalledTimes(1)
|
||||
|
||||
sourceId = 'com.apple.inputmethod.SCIM.ITABC'
|
||||
now.mockReturnValue(2001)
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' }))
|
||||
await vi.waitFor(() => expect(readInputSourceId).toHaveBeenCalledTimes(2))
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
tracker.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('refreshes on modifier keyup so active sources can become disabled while focused', async () => {
|
||||
let sourceId = 'com.apple.inputmethod.SCIM.ITABC'
|
||||
const tracker = createMacNativeTextInputSourceTracker(window, {
|
||||
readInputSourceId: async () => sourceId
|
||||
})
|
||||
await tracker.refresh()
|
||||
expect(tracker.isActive()).toBe(true)
|
||||
|
||||
sourceId = 'com.apple.keylayout.ABC'
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', ctrlKey: true }))
|
||||
|
||||
await vi.waitFor(() => expect(tracker.isActive()).toBe(false))
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('keeps the singleton reusable for terminal lifecycle code', () => {
|
||||
const first = getMacNativeTextInputSourceTracker()
|
||||
const second = getMacNativeTextInputSourceTracker()
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
})
|
||||
@@ -1,221 +0,0 @@
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
export type MacNativeTextInputSourceFeatures = Readonly<{
|
||||
forwardHangulJamo: boolean
|
||||
forwardAsciiPunctuation: boolean
|
||||
forwardShortTextReplacements: boolean
|
||||
}>
|
||||
|
||||
export type MacNativeTextInputSourceTracker = IDisposable & {
|
||||
isActive: () => boolean
|
||||
getFeatures: () => MacNativeTextInputSourceFeatures
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
type KeyboardInputSourceReader = () => Promise<string | null>
|
||||
|
||||
export const DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: false
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const CJK_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const KOREAN_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: true,
|
||||
forwardAsciiPunctuation: true,
|
||||
forwardShortTextReplacements: false
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const VIETNAMESE_NATIVE_TEXT_INPUT_SOURCE_FEATURES = Object.freeze({
|
||||
forwardHangulJamo: false,
|
||||
forwardAsciiPunctuation: false,
|
||||
forwardShortTextReplacements: true
|
||||
}) satisfies MacNativeTextInputSourceFeatures
|
||||
|
||||
const CJK_INPUT_SOURCE_TERMS = [
|
||||
'bytedance',
|
||||
'cangjie',
|
||||
'chinese',
|
||||
'doubao',
|
||||
'hangul',
|
||||
'hanin',
|
||||
'hiragana',
|
||||
'itabc',
|
||||
'japanese',
|
||||
'kana',
|
||||
'katakana',
|
||||
'korean',
|
||||
'kotoeri',
|
||||
'pinyin',
|
||||
'rime',
|
||||
'romaji',
|
||||
'scim',
|
||||
'shuangpin',
|
||||
'stroke',
|
||||
'tcim',
|
||||
'wubi',
|
||||
'wubihua',
|
||||
'zhuyin'
|
||||
] as const
|
||||
|
||||
const VIETNAMESE_INPUT_SOURCE_TERMS = ['telex', 'unikey', 'vietnam', 'vni'] as const
|
||||
const KOREAN_INPUT_SOURCE_TERMS = ['hangul', 'korean'] as const
|
||||
|
||||
const KEYBOARD_ACTIVITY_REFRESH_COOLDOWN_MS = 1000
|
||||
|
||||
function defaultKeyboardInputSourceReader(): KeyboardInputSourceReader {
|
||||
return async () => {
|
||||
const api = (
|
||||
globalThis as {
|
||||
window?: {
|
||||
api?: {
|
||||
app?: {
|
||||
getKeyboardInputSourceId?: () => Promise<string | null>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).window?.api
|
||||
const reader = api?.app?.getKeyboardInputSourceId
|
||||
if (!reader) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await reader()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMacNativeTextInputSourceFeatures(
|
||||
id: string | null | undefined
|
||||
): MacNativeTextInputSourceFeatures {
|
||||
const normalized = id?.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
if (KOREAN_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) {
|
||||
return KOREAN_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
if (CJK_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) {
|
||||
return CJK_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
if (VIETNAMESE_INPUT_SOURCE_TERMS.some((term) => normalized.includes(term))) {
|
||||
return VIETNAMESE_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
return DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
}
|
||||
|
||||
export function createMacNativeTextInputSourceTracker(
|
||||
win: Window = window,
|
||||
options: { readInputSourceId?: KeyboardInputSourceReader } = {}
|
||||
): MacNativeTextInputSourceTracker {
|
||||
const readInputSourceId = options.readInputSourceId ?? defaultKeyboardInputSourceReader()
|
||||
let features: MacNativeTextInputSourceFeatures = DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
let disposed = false
|
||||
let refreshGeneration = 0
|
||||
let refreshInFlight = false
|
||||
let refreshQueued = false
|
||||
let lastKeyboardActivityRefreshAt: number | null = null
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const generation = ++refreshGeneration
|
||||
let inputSourceId: string | null = null
|
||||
try {
|
||||
inputSourceId = await readInputSourceId()
|
||||
} catch {
|
||||
inputSourceId = null
|
||||
}
|
||||
if (disposed || generation !== refreshGeneration) {
|
||||
return
|
||||
}
|
||||
features = getMacNativeTextInputSourceFeatures(inputSourceId)
|
||||
}
|
||||
|
||||
const requestRefresh = (): void => {
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true
|
||||
return
|
||||
}
|
||||
refreshInFlight = true
|
||||
void refresh().finally(() => {
|
||||
refreshInFlight = false
|
||||
if (!disposed && refreshQueued) {
|
||||
refreshQueued = false
|
||||
requestRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onFocus = (): void => {
|
||||
requestRefresh()
|
||||
}
|
||||
|
||||
const requestKeyboardActivityRefresh = (force: boolean): void => {
|
||||
const now = Date.now()
|
||||
if (
|
||||
!force &&
|
||||
lastKeyboardActivityRefreshAt !== null &&
|
||||
now - lastKeyboardActivityRefreshAt < KEYBOARD_ACTIVITY_REFRESH_COOLDOWN_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastKeyboardActivityRefreshAt = now
|
||||
requestRefresh()
|
||||
}
|
||||
|
||||
const onKeyboardActivity = (event: KeyboardEvent): void => {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
requestKeyboardActivityRefresh(true)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!features.forwardHangulJamo &&
|
||||
!features.forwardAsciiPunctuation &&
|
||||
!features.forwardShortTextReplacements
|
||||
) {
|
||||
requestKeyboardActivityRefresh(false)
|
||||
}
|
||||
}
|
||||
|
||||
win.addEventListener('focus', onFocus)
|
||||
// Why: macOS input-source changes can happen while the terminal keeps focus;
|
||||
// refresh from keyboard activity so CJK punctuation gates do not stay stale.
|
||||
win.addEventListener('keydown', onKeyboardActivity, true)
|
||||
win.addEventListener('keyup', onKeyboardActivity, true)
|
||||
requestRefresh()
|
||||
|
||||
return {
|
||||
isActive: () =>
|
||||
features.forwardHangulJamo ||
|
||||
features.forwardAsciiPunctuation ||
|
||||
features.forwardShortTextReplacements,
|
||||
getFeatures: () => features,
|
||||
refresh,
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
win.removeEventListener('focus', onFocus)
|
||||
win.removeEventListener('keydown', onKeyboardActivity, true)
|
||||
win.removeEventListener('keyup', onKeyboardActivity, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: MacNativeTextInputSourceTracker | null = null
|
||||
|
||||
export function getMacNativeTextInputSourceTracker(): MacNativeTextInputSourceTracker {
|
||||
singleton ??= createMacNativeTextInputSourceTracker()
|
||||
return singleton
|
||||
}
|
||||
|
||||
export function _resetMacNativeTextInputSourceTrackerForTests(): void {
|
||||
singleton?.dispose()
|
||||
singleton = null
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Reuses xterm's own kitty encoder rather than hand-rolling CSI-u. It lives in
|
||||
// the package's `src/` tree and is absent from the public typings, so this is a
|
||||
// deep import into a pinned dependency — acceptable here because the version is
|
||||
// already pinned by a patch that would fail to apply across a bump.
|
||||
import { KittyKeyboard } from '@xterm/xterm/src/common/input/KittyKeyboard'
|
||||
|
||||
/**
|
||||
* `report_all_keys_as_escape_codes`. Bit 3 is the only flag that changes what a
|
||||
* plain printable key should put on the wire; 1/2/4/16 leave it as text.
|
||||
*/
|
||||
const KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES = 0b1000
|
||||
|
||||
/**
|
||||
* `KittyKeyboardEventType.PRESS` / `.REPEAT`. Inlined because the upstream enum is a
|
||||
* `const enum`, which does not survive an import across module boundaries.
|
||||
*/
|
||||
const KITTY_EVENT_TYPE_PRESS = 1
|
||||
const KITTY_EVENT_TYPE_REPEAT = 2
|
||||
|
||||
const kittyKeyboardEncoder = new KittyKeyboard()
|
||||
|
||||
/** The physical keydown that produced a commit, captured before the input event. */
|
||||
export type ImeCommitKeyPress = {
|
||||
key: string
|
||||
code?: string
|
||||
shiftKey: boolean
|
||||
/** An auto-repeat keydown; the protocol distinguishes it from a fresh press. */
|
||||
repeat?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane that negotiated bit 3 asked for every printable key as a CSI-u report,
|
||||
* so writing IME-committed text raw hands it the legacy byte stream it declined.
|
||||
* Re-encode the press that produced the commit instead.
|
||||
*
|
||||
* The gate is bit 3 ALONE. "Kitty is active" and `flags !== 0` are both wrong:
|
||||
* a pane negotiating only disambiguation or event types still expects printable
|
||||
* keys as text, and encoding there would drop every substituted character.
|
||||
*
|
||||
* Returns null when the commit should be written raw, which is every case except
|
||||
* bit 3.
|
||||
*
|
||||
* Known limit: the report carries the *physical* key's codepoint, not the
|
||||
* committed glyph — bit 3 is the app declaring it does not want text, and bit 4
|
||||
* (`report_associated_text`) is how it asks for text back. xterm's encoder
|
||||
* derives that text field from the same `key` it derives the keycode from, so
|
||||
* carrying the committed glyph under bit 4 needs an encoder change, not a flag.
|
||||
*/
|
||||
export function encodeImeCommitAsKittyReport(
|
||||
press: ImeCommitKeyPress | null,
|
||||
kittyKeyboardFlags: number
|
||||
): string | null {
|
||||
if ((kittyKeyboardFlags & KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES) === 0 || !press) {
|
||||
return null
|
||||
}
|
||||
// Why: the forwarder only claims presses with no control chord, so the
|
||||
// modifier fields are known-false rather than read from a live event.
|
||||
const encoded = kittyKeyboardEncoder.evaluate(
|
||||
{
|
||||
type: 'keydown',
|
||||
key: press.key,
|
||||
code: press.code ?? '',
|
||||
keyCode: 0,
|
||||
shiftKey: press.shiftKey,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
},
|
||||
kittyKeyboardFlags,
|
||||
// Why: a held key emits repeated keydowns, and the protocol reports those as REPEAT.
|
||||
// Defaulting them all to PRESS would make one held key look like N separate strikes to
|
||||
// an app that counts presses or filters repeats.
|
||||
press.repeat === true ? KITTY_EVENT_TYPE_REPEAT : KITTY_EVENT_TYPE_PRESS
|
||||
)
|
||||
return encoded.key ?? null
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// @vitest-environment happy-dom
|
||||
// #11170: a ~/Library/KeyBindings/DefaultKeyBinding.dict remap is honoured everywhere on macOS
|
||||
// except the terminal, which sends the raw layout character to the PTY.
|
||||
//
|
||||
// The OS applies the substitution inside the AppKit text input path, so it exists only on
|
||||
// keypress.charCode and on the input event's `data` — keydown.key still carries the layout
|
||||
// character. Nothing here needs to parse the dict: Chromium's renderer is already the text
|
||||
// system's client and has applied it by the time `input` fires. The bug was that the keydown
|
||||
// manufactured a byte and preventDefault'd, tearing the text pipeline down before the
|
||||
// substitution could arrive.
|
||||
//
|
||||
// This pins the behaviour across a design change rather than introducing it. The reported build
|
||||
// sent the raw character; a later punctuation table happened to list that one character, which
|
||||
// closed the issue by enumeration. Replacing the table with a structural claim removes the entry,
|
||||
// so without a test the fix could regress silently on a change that never mentions #11170.
|
||||
// Both designs fail this file when their respective claim is removed.
|
||||
//
|
||||
// The harness supplies no input-source classification, which models a source the older design did
|
||||
// not recognise — including the window before its async probe resolves. That is the condition
|
||||
// under which the second layout arm separates the two designs: with the source recognised the
|
||||
// older one claimed all ASCII punctuation and covered it too, so the gap was real but conditional.
|
||||
// The structural claim has no such condition, which is the point.
|
||||
//
|
||||
// Replays the recorded event shape from the issue rather than an authored one, and pairs it with
|
||||
// the same physical key carrying no substitution — a fix that rewrote the Backquote position
|
||||
// unconditionally would pass the positive case and be badly wrong.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import trace from './__fixtures__/macos-keybinding-dict-backquote-trace.json'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy'
|
||||
|
||||
type RecordedEvent = {
|
||||
type: string
|
||||
key?: string
|
||||
code?: string
|
||||
keyCode?: number
|
||||
charCode?: number
|
||||
data?: string
|
||||
inputType?: string
|
||||
isComposing?: boolean
|
||||
value?: string
|
||||
}
|
||||
|
||||
type RecordedCase = {
|
||||
name: string
|
||||
expectedPty: string
|
||||
dom: RecordedEvent[]
|
||||
}
|
||||
|
||||
const CASES = trace.cases as RecordedCase[]
|
||||
|
||||
function caseNamed(name: string): RecordedCase {
|
||||
const found = CASES.find((entry) => entry.name === name)
|
||||
if (!found) {
|
||||
throw new Error(`fixture case '${name}' missing`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function buildEvent(recorded: RecordedEvent): Event {
|
||||
if (recorded.type === 'input' || recorded.type === 'beforeinput') {
|
||||
const input = new InputEvent(recorded.type, {
|
||||
isComposing: recorded.isComposing,
|
||||
bubbles: true
|
||||
})
|
||||
// happy-dom drops these from InputEventInit; Chromium supplies them.
|
||||
Object.defineProperty(input, 'inputType', { value: recorded.inputType ?? '' })
|
||||
Object.defineProperty(input, 'data', { value: recorded.data ?? null })
|
||||
Object.defineProperty(input, 'composed', { value: true })
|
||||
return input
|
||||
}
|
||||
const keyboard = new KeyboardEvent(recorded.type, {
|
||||
key: recorded.key,
|
||||
code: recorded.code,
|
||||
isComposing: recorded.isComposing,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode ?? 0 })
|
||||
Object.defineProperty(keyboard, 'charCode', { value: recorded.charCode ?? 0 })
|
||||
return keyboard
|
||||
}
|
||||
|
||||
/** Mirrors the handler order in use-terminal-pane-lifecycle.ts: the native-text claim,
|
||||
* then the bypass policy. */
|
||||
function open() {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => false,
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (forwarder.claimKeyEvent(event)) {
|
||||
return false
|
||||
}
|
||||
return !shouldBypassXtermKeyboardEvent(event, {
|
||||
isMac: true,
|
||||
hasSelection: false,
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
})
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
return { emitted, terminal, forwarder }
|
||||
}
|
||||
|
||||
/** Replays recorded cases in order and returns the bytes that reached the PTY. */
|
||||
function replay(names: string[]): string {
|
||||
const { emitted, terminal, forwarder } = open()
|
||||
const textarea = terminal.textarea!
|
||||
for (const name of names) {
|
||||
for (const recorded of caseNamed(name).dom) {
|
||||
const event = buildEvent(recorded)
|
||||
// Why: the recorded keydown is what a real browser only emits the rest of the sequence
|
||||
// after, so a claim that preventDefaults must truncate the replay exactly as Chromium would.
|
||||
if (recorded.type !== 'keydown' && recorded.type !== 'keyup') {
|
||||
textarea.value = recorded.value ?? ''
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
}
|
||||
textarea.dispatchEvent(event)
|
||||
if (recorded.type === 'keydown' && event.defaultPrevented) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
forwarder.dispose()
|
||||
terminal.dispose()
|
||||
return emitted.join('')
|
||||
}
|
||||
|
||||
describe('#11170 — a DefaultKeyBinding.dict remap reaches the PTY', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('sends the remapped character, not the raw layout character', () => {
|
||||
expect(replay(['remapped'])).toBe(caseNamed('remapped').expectedPty)
|
||||
})
|
||||
|
||||
// Same physical key, same dict entry, on the Korean layout that puts an asterisk there instead.
|
||||
// This is the arm that discriminates without a mutation: the older design honoured the
|
||||
// substitution by listing characters, and listed the reported one but not this one.
|
||||
it('sends the remapped character on the other Korean layout too', () => {
|
||||
const remapped = caseNamed('remapped-other-korean-layout')
|
||||
expect(replay([remapped.name])).toBe(remapped.expectedPty)
|
||||
})
|
||||
|
||||
// The paired negative. Same physical key, same keydown.key, no substitution in play.
|
||||
it('still sends the layout character when nothing remaps the key', () => {
|
||||
expect(replay(['no-remap-korean'])).toBe(caseNamed('no-remap-korean').expectedPty)
|
||||
})
|
||||
|
||||
it('still sends the plain backquote under an ASCII layout', () => {
|
||||
expect(replay(['no-remap-ascii'])).toBe(caseNamed('no-remap-ascii').expectedPty)
|
||||
})
|
||||
|
||||
it('leaves the next keystroke untouched', () => {
|
||||
expect(replay(['remapped', 'remapped-neighbour-key'])).toBe('`a')
|
||||
})
|
||||
|
||||
// The dict's "~₩" rule (Option+the key -> the layout character) is out of scope: Option chords
|
||||
// are deliberately excluded from the claim, and the option-as-alt setting consumes Option first.
|
||||
it('does not claim the Option chord the dict also binds', () => {
|
||||
const { emitted, terminal, forwarder } = open()
|
||||
const textarea = terminal.textarea!
|
||||
const chord = new KeyboardEvent('keydown', {
|
||||
key: '₩',
|
||||
code: 'Backquote',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(chord, 'keyCode', { value: 192 })
|
||||
textarea.dispatchEvent(chord)
|
||||
forwarder.dispose()
|
||||
terminal.dispose()
|
||||
expect(emitted.join('')).not.toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
type MacNativeTextInputSourceFeatures
|
||||
} from './terminal-ime-input-source'
|
||||
|
||||
export type ImeNativeTextKeyEvent = {
|
||||
type: string
|
||||
key: string
|
||||
code?: string
|
||||
keyCode?: number
|
||||
which?: number
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
altKey: boolean
|
||||
isComposing?: boolean
|
||||
}
|
||||
|
||||
const CJK_DIRECT_PUNCTUATION_KEYS = new Set<string>([
|
||||
'、',
|
||||
'。',
|
||||
',',
|
||||
'.',
|
||||
'!',
|
||||
'?',
|
||||
';',
|
||||
':',
|
||||
'“',
|
||||
'”',
|
||||
'‘',
|
||||
'’',
|
||||
'(',
|
||||
')',
|
||||
'【',
|
||||
'】',
|
||||
'《',
|
||||
'》',
|
||||
'〈',
|
||||
'〉',
|
||||
'「',
|
||||
'」',
|
||||
'『',
|
||||
'』',
|
||||
'¥',
|
||||
// Korean sources put ₩ on Backquote; its committed text can differ from
|
||||
// `key` when DefaultKeyBinding.dict rewrites it (typically back to `).
|
||||
'₩',
|
||||
'~',
|
||||
'·',
|
||||
'…'
|
||||
])
|
||||
|
||||
function isSingleAsciiKey(key: string): number | null {
|
||||
// Reject multi-codepoint keys ("Enter", "ArrowLeft", emoji, ...).
|
||||
if (Array.from(key).length !== 1) {
|
||||
return null
|
||||
}
|
||||
return key.codePointAt(0) ?? null
|
||||
}
|
||||
|
||||
function isAsciiDigitCode(code: number): boolean {
|
||||
return code >= 0x30 && code <= 0x39
|
||||
}
|
||||
|
||||
function isUpperAsciiLetterCode(code: number): boolean {
|
||||
return code >= 0x41 && code <= 0x5a
|
||||
}
|
||||
|
||||
function isLowerAsciiLetterCode(code: number): boolean {
|
||||
return code >= 0x61 && code <= 0x7a
|
||||
}
|
||||
|
||||
function isAsciiPunctuationKey(key: string): boolean {
|
||||
const code = isSingleAsciiKey(key)
|
||||
if (code === null) {
|
||||
return false
|
||||
}
|
||||
const isDigit = isAsciiDigitCode(code)
|
||||
const isUpperAlpha = isUpperAsciiLetterCode(code)
|
||||
const isLowerAlpha = isLowerAsciiLetterCode(code)
|
||||
// Printable ASCII excluding space (0x20), digits and letters: the keys an
|
||||
// IME may swap for a full-width or CJK glyph.
|
||||
return code > 0x20 && code <= 0x7e && !isDigit && !isUpperAlpha && !isLowerAlpha
|
||||
}
|
||||
|
||||
function isCjkDirectPunctuationKey(key: string): boolean {
|
||||
return Array.from(key).length === 1 && CJK_DIRECT_PUNCTUATION_KEYS.has(key)
|
||||
}
|
||||
|
||||
function isHangulJamoKey(key: string): boolean {
|
||||
const chars = Array.from(key)
|
||||
if (chars.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const codePoint = chars[0].codePointAt(0)!
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xd7b0 && codePoint <= 0xd7ff) ||
|
||||
(codePoint >= 0xffa0 && codePoint <= 0xffdc)
|
||||
)
|
||||
}
|
||||
|
||||
function isAsciiShortTextReplacementKey(key: string): boolean {
|
||||
const code = isSingleAsciiKey(key)
|
||||
if (code === null) {
|
||||
return false
|
||||
}
|
||||
return isAsciiDigitCode(code) || isUpperAsciiLetterCode(code) || isLowerAsciiLetterCode(code)
|
||||
}
|
||||
|
||||
export function isSinglePrintableTextKey(key: string): boolean {
|
||||
const chars = Array.from(key)
|
||||
if (chars.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const codePoint = chars[0].codePointAt(0)
|
||||
return codePoint !== undefined && codePoint >= 0x20 && codePoint !== 0x7f
|
||||
}
|
||||
|
||||
function hasUnreliablePhysicalKeyIdentity(event: ImeNativeTextKeyEvent): boolean {
|
||||
const code = event.code?.trim()
|
||||
const legacyKeyCode = event.keyCode ?? event.which
|
||||
return !code || code === 'Unidentified' || legacyKeyCode === 0
|
||||
}
|
||||
|
||||
function isSyntheticUnicodeTextKey(event: ImeNativeTextKeyEvent): boolean {
|
||||
if (!hasUnreliablePhysicalKeyIdentity(event)) {
|
||||
return false
|
||||
}
|
||||
// CGEventKeyboardSetUnicodeString-style injectors can surface as
|
||||
// `Unidentified` keydowns; the following `input` event carries the text.
|
||||
if (event.key === 'Unidentified') {
|
||||
return true
|
||||
}
|
||||
return isSinglePrintableTextKey(event.key)
|
||||
}
|
||||
|
||||
export function isImeNativeTextKeydownCandidate(
|
||||
event: ImeNativeTextKeyEvent,
|
||||
compositionActive: boolean,
|
||||
inputSourceFeatures: MacNativeTextInputSourceFeatures = DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
): boolean {
|
||||
if (event.type !== 'keydown') {
|
||||
return false
|
||||
}
|
||||
// Modifier chords are real shortcuts (Ctrl+C, Cmd+V, Alt+...); never a plain
|
||||
// native text commit. Shift is allowed since punctuation and uppercase
|
||||
// Vietnamese text can legitimately need it.
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
// Composing keystrokes belong to the IME preedit and xterm's CompositionHelper
|
||||
// (which already forwards the committed text), so leave them alone.
|
||||
if (event.isComposing === true || compositionActive) {
|
||||
return false
|
||||
}
|
||||
if (isSyntheticUnicodeTextKey(event)) {
|
||||
return true
|
||||
}
|
||||
if (inputSourceFeatures.forwardHangulJamo && isHangulJamoKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
if (isCjkDirectPunctuationKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
if (inputSourceFeatures.forwardAsciiPunctuation && isAsciiPunctuationKey(event.key)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
inputSourceFeatures.forwardShortTextReplacements && isAsciiShortTextReplacementKey(event.key)
|
||||
)
|
||||
}
|
||||
+343
-615
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,33 @@
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
type MacNativeTextInputSourceFeatures
|
||||
} from './terminal-ime-input-source'
|
||||
import {
|
||||
isImeNativeTextKeydownCandidate,
|
||||
isSinglePrintableTextKey,
|
||||
type ImeNativeTextKeyEvent
|
||||
} from './terminal-ime-native-text-candidates'
|
||||
import { encodeImeCommitAsKittyReport } from './terminal-ime-kitty-commit-encoding'
|
||||
|
||||
// Why: some macOS input sources and synthetic Unicode injectors commit native
|
||||
// text through a plain `insertText` event after a printable keydown. Xterm's
|
||||
// kitty keyboard protocol can encode and cancel that keydown before Chromium
|
||||
// commits the real text, so this narrowly bypasses known native-text candidates
|
||||
// and forwards the committed glyph from the input event straight to the PTY.
|
||||
// Why: a plain printable keydown never produces terminal bytes. Bytes for
|
||||
// printable characters come only from the `input` event, which on macOS *is*
|
||||
// the text system's commit callback and carries whatever the input source
|
||||
// actually produced (`,` for `,`, `、` for `\`, `——` for a single press).
|
||||
// Xterm would otherwise send the raw layout character from the keydown and then
|
||||
// preventDefault, destroying the committed text before Chromium can deliver it.
|
||||
//
|
||||
// The claim is structural, so it holds for input sources that do not exist yet:
|
||||
// no input-source identity is read, and `key` is only ever measured for length.
|
||||
|
||||
type ClaimedKeyPress = {
|
||||
key: string
|
||||
code?: string
|
||||
shiftKey: boolean
|
||||
repeat?: boolean
|
||||
}
|
||||
|
||||
export type ImeNativeTextKeyEvent = {
|
||||
type: string
|
||||
key: string
|
||||
code?: string
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
altKey: boolean
|
||||
shiftKey?: boolean
|
||||
repeat?: boolean
|
||||
isComposing?: boolean
|
||||
}
|
||||
|
||||
export const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT = 'xterm-composition-transaction-accepted'
|
||||
@@ -27,12 +37,45 @@ export type TerminalImeNativeTextForwarder = IDisposable & {
|
||||
/**
|
||||
* Returns true when this keyboard event belongs to a direct native text
|
||||
* commit and should bypass xterm (the caller should return `false` from
|
||||
* `attachCustomKeyEventHandler`). The committed glyph is forwarded later from
|
||||
* `attachCustomKeyEventHandler`). The committed text is forwarded later from
|
||||
* the `input` event via the `sendInput` dependency.
|
||||
*/
|
||||
claimKeyEvent: (event: ImeNativeTextKeyEvent) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A single printable keystroke with no control chord and no live composition.
|
||||
*
|
||||
* `key` is read for LENGTH ONLY, never identity — that is what makes the
|
||||
* predicate invariant under the `key` rewrite a CJK input source performs, and
|
||||
* why no punctuation table is needed. Length also excludes named keys (`Enter`,
|
||||
* `ArrowLeft`, `Dead`, `F3`) without enumerating them.
|
||||
*
|
||||
* Claiming a keydown withholds its byte until the commit arrives, so a key the
|
||||
* IME eats without committing would be dropped. That is bounded, not a gap:
|
||||
* across 12,040 recorded keydowns there is no such case. The browser marks
|
||||
* IME-owned presses on the keydown itself — `keyCode 229` on macOS even while
|
||||
* `key` is still a single translated character — and all 4,453 such presses in
|
||||
* the corpus were followed by a composition event. It is a positive marker
|
||||
* only: fcitx5 on Wayland omits it, so it cannot be inverted into a gate.
|
||||
* Stale claims clear on the next keydown rather than on a timer; a timer here
|
||||
* once wrote a newline the user never typed.
|
||||
*/
|
||||
function isNativeTextKeydown(event: ImeNativeTextKeyEvent, compositionActive: boolean): boolean {
|
||||
return (
|
||||
event.type === 'keydown' &&
|
||||
// Control chords are the byte-producing case and belong to xterm's encoder.
|
||||
// Shift stays eligible: shifted punctuation still commits substituted text.
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
event.key.length === 1 &&
|
||||
// Composing keystrokes already belong to xterm's composition helper.
|
||||
event.isComposing !== true &&
|
||||
!compositionActive
|
||||
)
|
||||
}
|
||||
|
||||
function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: ClaimedKeyPress): boolean {
|
||||
if (event.code && claimedPress.code) {
|
||||
return event.code === claimedPress.code
|
||||
@@ -40,26 +83,16 @@ function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: Claimed
|
||||
return event.key === claimedPress.key
|
||||
}
|
||||
|
||||
function matchesClaimedKeypress(
|
||||
event: ImeNativeTextKeyEvent,
|
||||
claimedPress: ClaimedKeyPress
|
||||
): boolean {
|
||||
if (matchesClaimedPress(event, claimedPress)) {
|
||||
return true
|
||||
}
|
||||
if (event.code && claimedPress.code) {
|
||||
return false
|
||||
}
|
||||
// Why: IME/native-text keypresses can carry the transformed glyph and omit
|
||||
// physical `code`; keep xterm silent until the input event forwards the text.
|
||||
return isSinglePrintableTextKey(event.key)
|
||||
}
|
||||
|
||||
export function installTerminalImeNativeTextForwarder(args: {
|
||||
terminalElement: HTMLElement | null | undefined
|
||||
isComposing: () => boolean
|
||||
sendInput: (data: string) => void
|
||||
getInputSourceFeatures?: () => MacNativeTextInputSourceFeatures
|
||||
/**
|
||||
* The pane's negotiated kitty flags. Read once per commit, never on the
|
||||
* keydown — `claimKeyEvent` stays structural and protocol-blind so the hot
|
||||
* path keeps no kitty state. Absent means no pane to negotiate with.
|
||||
*/
|
||||
getKittyKeyboardFlags?: () => number
|
||||
}): TerminalImeNativeTextForwarder {
|
||||
if (!args.terminalElement) {
|
||||
return {
|
||||
@@ -70,31 +103,10 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
|
||||
const terminalElement = args.terminalElement
|
||||
let pendingForward = false
|
||||
let pendingForwardClearTimer: number | null = null
|
||||
let compositionTransactionPending = false
|
||||
let claimedPress: ClaimedKeyPress | null = null
|
||||
|
||||
const clearPendingForwardTimer = (): void => {
|
||||
if (pendingForwardClearTimer !== null) {
|
||||
window.clearTimeout(pendingForwardClearTimer)
|
||||
pendingForwardClearTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const disarmPendingForward = (): void => {
|
||||
clearPendingForwardTimer()
|
||||
pendingForward = false
|
||||
}
|
||||
|
||||
const schedulePendingForwardClear = (): void => {
|
||||
clearPendingForwardTimer()
|
||||
// Why: some macOS IMEs deliver keyup before the final insertText event;
|
||||
// keep the native commit armed briefly, then drop genuinely stray inserts.
|
||||
pendingForwardClearTimer = window.setTimeout(() => {
|
||||
pendingForward = false
|
||||
pendingForwardClearTimer = null
|
||||
}, 100)
|
||||
}
|
||||
/** Whether the claimed press actually reached the pty, which decides if its release does. */
|
||||
let forwardedPressBytes = false
|
||||
|
||||
const markCompositionTransactionAccepted = (): void => {
|
||||
compositionTransactionPending = true
|
||||
@@ -106,19 +118,19 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
|
||||
const claimKeyEvent = (event: ImeNativeTextKeyEvent): boolean => {
|
||||
if (event.type === 'keydown') {
|
||||
if (
|
||||
!isImeNativeTextKeydownCandidate(
|
||||
event,
|
||||
args.isComposing(),
|
||||
args.getInputSourceFeatures?.() ?? DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
)
|
||||
) {
|
||||
if (!isNativeTextKeydown(event, args.isComposing())) {
|
||||
return false
|
||||
}
|
||||
// Arm forwarding so the upcoming input event is sent to the PTY.
|
||||
clearPendingForwardTimer()
|
||||
// Why: re-arming here is also what drops a stale claim whose input event
|
||||
// never arrived (the input source swallowed the key) — no timer needed.
|
||||
pendingForward = true
|
||||
claimedPress = { key: event.key, code: event.code }
|
||||
forwardedPressBytes = false
|
||||
claimedPress = {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
repeat: event.repeat === true
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!claimedPress) {
|
||||
@@ -131,51 +143,57 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
if (!matchesClaimedPress(event, claimedPress)) {
|
||||
return false
|
||||
}
|
||||
const pressReachedThePty = forwardedPressBytes
|
||||
claimedPress = null
|
||||
if (pendingForward) {
|
||||
schedulePendingForwardClear()
|
||||
}
|
||||
// Bypass so the kitty release sequence for the swallowed press cannot leak.
|
||||
return true
|
||||
forwardedPressBytes = false
|
||||
// Why: a release report describes a press the app received. Suppress it only when
|
||||
// this press put nothing on the wire — swallowed by the input source, or owned by a
|
||||
// composition transaction. Suppressing unconditionally would drop the kitty release
|
||||
// for ordinary typing, because the structural claim takes every printable keydown
|
||||
// rather than the short punctuation list the previous design claimed.
|
||||
return !pressReachedThePty
|
||||
}
|
||||
if (event.type === 'keypress') {
|
||||
// Keep the keydown's armed state but still bypass xterm so it does not
|
||||
// double-send printable text before our input forward runs.
|
||||
return matchesClaimedKeypress(event, claimedPress)
|
||||
}
|
||||
return false
|
||||
// Keep the keydown's armed state but still bypass xterm so it does not
|
||||
// double-send printable text before our input forward runs.
|
||||
return event.type === 'keypress'
|
||||
}
|
||||
|
||||
const forwardCommittedText = (event: Event): void => {
|
||||
if (!(event instanceof InputEvent)) {
|
||||
return
|
||||
}
|
||||
// Why: an accepted composition transaction already owns its commit; letting
|
||||
// it through here would send the text a second time.
|
||||
if (compositionTransactionPending && event.inputType === 'insertText') {
|
||||
disarmPendingForward()
|
||||
pendingForward = false
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (!pendingForward) {
|
||||
return
|
||||
}
|
||||
pendingForward = false
|
||||
if (event.inputType !== 'insertText') {
|
||||
disarmPendingForward()
|
||||
return
|
||||
}
|
||||
disarmPendingForward()
|
||||
if (event.data) {
|
||||
args.sendInput(event.data)
|
||||
const kittyReport = encodeImeCommitAsKittyReport(
|
||||
claimedPress,
|
||||
args.getKittyKeyboardFlags?.() ?? 0
|
||||
)
|
||||
args.sendInput(kittyReport ?? event.data)
|
||||
forwardedPressBytes = true
|
||||
}
|
||||
event.stopImmediatePropagation()
|
||||
// Clear the helper textarea so the native glyph doesn't accumulate;
|
||||
// safe in practice since only synthetic injectors interleave commits.
|
||||
// Clear the helper textarea so the committed text doesn't accumulate.
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPending = (): void => {
|
||||
disarmPendingForward()
|
||||
pendingForward = false
|
||||
forwardedPressBytes = false
|
||||
compositionTransactionPending = false
|
||||
claimedPress = null
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
// @vitest-environment happy-dom
|
||||
// An input source that substitutes text for a printable key commits it through a
|
||||
// bare `insertText` with no composition session. Drives a real Terminal wired the
|
||||
// way the pane lifecycle wires it, so it covers the whole keydown -> input path.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy'
|
||||
|
||||
type SubstitutionCase = {
|
||||
name: string
|
||||
code: string
|
||||
keyCode: number
|
||||
shiftKey?: boolean
|
||||
layoutText: string
|
||||
imeText: string
|
||||
}
|
||||
|
||||
function open(kittyKeyboardFlags = 0) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea!
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => false,
|
||||
sendInput: (data) => terminal.input(data),
|
||||
getKittyKeyboardFlags: () => kittyKeyboardFlags
|
||||
})
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (forwarder.claimKeyEvent(event)) {
|
||||
return false
|
||||
}
|
||||
return !shouldBypassXtermKeyboardEvent(event, {
|
||||
isMac: true,
|
||||
hasSelection: false,
|
||||
kittyKeyboardFlags
|
||||
})
|
||||
})
|
||||
const emitted: string[] = []
|
||||
terminal.onData((d) => emitted.push(d))
|
||||
return { emitted, terminal, textarea, forwarder }
|
||||
}
|
||||
|
||||
function key(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: string,
|
||||
init: { key: string; code: string; keyCode: number; shiftKey: boolean }
|
||||
): KeyboardEvent {
|
||||
const ev = new KeyboardEvent(type, {
|
||||
key: init.key,
|
||||
code: init.code,
|
||||
shiftKey: init.shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(ev, 'keyCode', { value: init.keyCode })
|
||||
Object.defineProperty(ev, 'charCode', { value: type === 'keypress' ? init.keyCode : 0 })
|
||||
textarea.dispatchEvent(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
function insertText(textarea: HTMLTextAreaElement, type: string, data: string): void {
|
||||
const ev = new InputEvent(type, { bubbles: true })
|
||||
Object.defineProperty(ev, 'inputType', { value: 'insertText' })
|
||||
Object.defineProperty(ev, 'data', { value: data })
|
||||
Object.defineProperty(ev, 'composed', { value: true })
|
||||
textarea.dispatchEvent(ev)
|
||||
}
|
||||
|
||||
function press(textarea: HTMLTextAreaElement, c: SubstitutionCase): void {
|
||||
const shiftKey = c.shiftKey === true
|
||||
const kd = key(textarea, 'keydown', {
|
||||
key: c.layoutText,
|
||||
code: c.code,
|
||||
keyCode: c.keyCode,
|
||||
shiftKey
|
||||
})
|
||||
if (!kd.defaultPrevented) {
|
||||
if (c.imeText.length === 1) {
|
||||
key(textarea, 'keypress', {
|
||||
key: c.imeText,
|
||||
code: c.code,
|
||||
keyCode: c.imeText.charCodeAt(0),
|
||||
shiftKey
|
||||
})
|
||||
}
|
||||
textarea.value = c.imeText
|
||||
textarea.setSelectionRange(c.imeText.length, c.imeText.length)
|
||||
insertText(textarea, 'beforeinput', c.imeText)
|
||||
insertText(textarea, 'input', c.imeText)
|
||||
}
|
||||
key(textarea, 'keyup', { key: c.layoutText, code: c.code, keyCode: c.keyCode, shiftKey })
|
||||
}
|
||||
|
||||
function type(cases: SubstitutionCase[], kitty = 0): string {
|
||||
const { emitted, terminal, textarea, forwarder } = open(kitty)
|
||||
for (const c of cases) {
|
||||
press(textarea, c)
|
||||
}
|
||||
forwarder.dispose()
|
||||
terminal.dispose()
|
||||
return emitted.join('')
|
||||
}
|
||||
|
||||
const COMMA: SubstitutionCase = {
|
||||
name: 'comma',
|
||||
code: 'Comma',
|
||||
keyCode: 188,
|
||||
layoutText: ',',
|
||||
imeText: ','
|
||||
}
|
||||
const PERIOD: SubstitutionCase = {
|
||||
name: 'period',
|
||||
code: 'Period',
|
||||
keyCode: 190,
|
||||
layoutText: '.',
|
||||
imeText: '。'
|
||||
}
|
||||
const QUESTION: SubstitutionCase = {
|
||||
name: 'question',
|
||||
code: 'Slash',
|
||||
keyCode: 191,
|
||||
shiftKey: true,
|
||||
layoutText: '?',
|
||||
imeText: '?'
|
||||
}
|
||||
const BACKSLASH: SubstitutionCase = {
|
||||
name: 'ideographic comma',
|
||||
code: 'Backslash',
|
||||
keyCode: 220,
|
||||
layoutText: '\\',
|
||||
imeText: '、'
|
||||
}
|
||||
const EM_DASH: SubstitutionCase = {
|
||||
name: 'em dash pair',
|
||||
code: 'Minus',
|
||||
keyCode: 189,
|
||||
shiftKey: true,
|
||||
layoutText: '_',
|
||||
imeText: '——'
|
||||
}
|
||||
const FULLWIDTH_ONE: SubstitutionCase = {
|
||||
name: 'full-width one',
|
||||
code: 'Digit1',
|
||||
keyCode: 49,
|
||||
layoutText: '1',
|
||||
imeText: '1'
|
||||
}
|
||||
const TELEX_A: SubstitutionCase = {
|
||||
name: 'telex a-acute',
|
||||
code: 'KeyS',
|
||||
keyCode: 83,
|
||||
layoutText: 's',
|
||||
imeText: 'á'
|
||||
}
|
||||
|
||||
describe('input-source text substitution reaches the terminal', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('sends the substituted sentence tail, not the raw layout characters', () => {
|
||||
expect(type([COMMA, PERIOD, PERIOD])).toBe(',。。')
|
||||
})
|
||||
it('sends a shifted substitution', () => {
|
||||
expect(type([QUESTION])).toBe('?')
|
||||
})
|
||||
it('sends the backslash-position substitution (#10896)', () => {
|
||||
expect(type([BACKSLASH])).toBe('、')
|
||||
})
|
||||
it('sends a multi-code-unit substitution from one press', () => {
|
||||
expect(type([EM_DASH])).toBe('——')
|
||||
})
|
||||
it('sends a full-width digit substitution', () => {
|
||||
expect(type([FULLWIDTH_ONE])).toBe('1')
|
||||
})
|
||||
it('sends a letter substitution', () => {
|
||||
expect(type([TELEX_A])).toBe('á')
|
||||
})
|
||||
it('sends the substitution with kitty disambiguate reporting negotiated', () => {
|
||||
expect(type([COMMA], 1)).toBe(',')
|
||||
})
|
||||
|
||||
// The gate is bit 3 alone. Every other flag leaves printable keys as text, so the substituted
|
||||
// character must still reach the pane; gating on "kitty active" instead would strip the
|
||||
// substitution from every pane that negotiates anything at all.
|
||||
describe.each([
|
||||
['none', 0],
|
||||
['disambiguate', 1],
|
||||
['event types', 2],
|
||||
['alternate keys', 4],
|
||||
['disambiguate + alternate keys', 5],
|
||||
['disambiguate + event types + alternate keys', 7],
|
||||
// Bit 4 asks for associated text, which only decorates a report bit 3 would already have
|
||||
// produced — on its own it does not turn a printable key into one.
|
||||
['associated text', 16]
|
||||
])('with kitty flags %s (%d) negotiated', (_name, flags) => {
|
||||
it('sends the substituted character raw', () => {
|
||||
expect(type([COMMA], flags)).toBe(',')
|
||||
})
|
||||
})
|
||||
|
||||
// Bit 3 is `report_all_keys_as_escape_codes`: the app asked for every printable key as a CSI-u
|
||||
// report, so committing raw UTF-8 hands it a byte stream it declined. Re-encode the press that
|
||||
// produced the commit. This is not CJK-specific — it is every printable key in such a pane.
|
||||
describe.each([
|
||||
['all keys as escape codes', 8],
|
||||
['all keys + disambiguate', 9],
|
||||
['all keys + disambiguate + event types + alternate keys', 15]
|
||||
])('with kitty flags %s (%d) negotiated', (_name, flags) => {
|
||||
it('sends a CSI-u report for the physical key instead of the substituted character', () => {
|
||||
// `,` is the physical Comma key; U+002C is 44. The committed `,` is deliberately absent —
|
||||
// see terminal-ime-kitty-commit-encoding.ts on why bit 3 without an encoder change cannot
|
||||
// carry it.
|
||||
expect(type([COMMA], flags)).toBe('\x1b[44u')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the physical key as the associated text under bit 3 + bit 4, not the substitution', () => {
|
||||
// Pins the limit named in terminal-ime-kitty-commit-encoding.ts: bit 4 is where the committed
|
||||
// glyph U+FF0C (65292) would ride, and 44 shows up in that slot instead. Closing that needs the
|
||||
// encoder to take the text separately from the key, so it is not reachable by widening a gate.
|
||||
expect(type([COMMA], 24)).toBe('\x1b[44;;44u')
|
||||
})
|
||||
|
||||
it('encodes a shifted substitution as a CSI-u report with the shift modifier', () => {
|
||||
// Shift is the one modifier the claim keeps eligible, so it has to survive the encoding.
|
||||
// 63 is `?`, not 47 for the unshifted `/`: xterm's encoder only unwinds a shifted key to its
|
||||
// base through `Digit*`/`Key*` codes, and this press carries `Slash`. Pinned as-is — that is
|
||||
// the shared encoder's behaviour for shifted punctuation on every path, not something the
|
||||
// commit path introduces.
|
||||
expect(type([QUESTION], 8)).toBe('\x1b[63;2u')
|
||||
})
|
||||
|
||||
it('encodes a multi-character substitution as one report, not one per character', () => {
|
||||
// One press produced `——`; bit 3 reports keys, and this was a single key.
|
||||
expect(type([EM_DASH], 8)).toBe('\x1b[95;2u')
|
||||
})
|
||||
})
|
||||
@@ -168,7 +168,15 @@ describe('Korean won input after a composition commit', () => {
|
||||
await expect(
|
||||
typeHangulThenWon(['₩'], {
|
||||
beforeWon: (textarea) => {
|
||||
// Why: the full sequence a browser emits for an unprevented printable
|
||||
// key. The keydown alone is not a shape Chromium can produce, and the
|
||||
// `input` event is what carries the character now.
|
||||
textarea.dispatchEvent(keyboardEvent('keydown', 'a', 'KeyA', 65))
|
||||
textarea.dispatchEvent(keyboardEvent('keypress', 'a', 'KeyA', 65))
|
||||
textarea.value += 'a'
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'a', inputType: 'insertText', bubbles: true })
|
||||
)
|
||||
}
|
||||
})
|
||||
).resolves.toBe('한a₩')
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* The preedit overlay must be VISIBLE while the IME is still composing, not merely populated.
|
||||
*
|
||||
* Recorded shape: Windows 11 / MS Korean 2-Set typed into a WSL pane, captured as the DOM event
|
||||
* stream on the xterm helper textarea. Cited capture:
|
||||
* `.tmp/ime-handoff/evidence/11919-windows-wsl-current/11919-current-owner.json` (gitignored;
|
||||
* the subsequences below are copied verbatim from indices 240-253 and 79-92 of its `trace`).
|
||||
*
|
||||
* The capture contains a shape none of the Linux traces do: after `compositionend`, the IME
|
||||
* RESUMES the composition with a bare `compositionupdate` and **no second `compositionstart`**.
|
||||
* xterm hides the overlay in `compositionend` (`_finalizeComposition` drops `.active`) and only
|
||||
* ever re-adds it in `compositionstart`, so the resumed preedit is written into an element that
|
||||
* is still `display: none`. The syllable commits normally afterwards, which is exactly the
|
||||
* reported symptom: the committed text lands but the user types the next one blind.
|
||||
*
|
||||
* The assertion is therefore on the DISPLAYED overlay — the `active` class that CSS keys
|
||||
* `display: block` off, together with the text in it. Asserting `onData` here would pass on the
|
||||
* broken build, because the commit path is not what breaks.
|
||||
*
|
||||
* happy-dom performs no layout, so the cell size is supplied and geometry is not asserted.
|
||||
*/
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const CELL_WIDTH_PX = 8
|
||||
const CELL_HEIGHT_PX = 16
|
||||
|
||||
type RecordedEvent = {
|
||||
type: string
|
||||
data?: string
|
||||
key?: string
|
||||
keyCode?: number
|
||||
isComposing?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Indices 240-253 of the capture: 네 commits, and the IME reopens on the next key with an
|
||||
* update alone. `keyCode: null` in the capture means the field was not recorded for that
|
||||
* event type, not that the event carried none.
|
||||
*/
|
||||
const RESUMED_WITHOUT_START: RecordedEvent[] = [
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '네' },
|
||||
{ type: 'beforeinput', data: '네', isComposing: true },
|
||||
{ type: 'input', data: '네', isComposing: true },
|
||||
{ type: 'compositionend', data: '네' },
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '네' },
|
||||
{ type: 'beforeinput', data: '네', isComposing: true },
|
||||
{ type: 'input', data: '네', isComposing: true }
|
||||
]
|
||||
|
||||
/** Indices 79-88: the same resume, this time across the Enter that committed 제. */
|
||||
const RESUMED_ACROSS_ENTER: RecordedEvent[] = [
|
||||
{ type: 'input', data: '제', isComposing: true },
|
||||
{ type: 'compositionend', data: '제' },
|
||||
{ type: 'keydown', key: 'Enter', keyCode: 13, isComposing: false },
|
||||
{ type: 'keyup', key: 'Process', keyCode: 229, isComposing: false },
|
||||
{ type: 'keyup', key: 'Enter', keyCode: 13, isComposing: false },
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '제' },
|
||||
{ type: 'beforeinput', data: '제', isComposing: true },
|
||||
{ type: 'input', data: '제', isComposing: true }
|
||||
]
|
||||
|
||||
/** The Linux control: IBus always reopens with a compositionstart, and must stay working. */
|
||||
const REOPENED_WITH_START: RecordedEvent[] = [
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: true },
|
||||
{ type: 'compositionupdate', data: '한' },
|
||||
{ type: 'compositionend', data: '한' },
|
||||
{ type: 'keydown', key: 'Process', keyCode: 229, isComposing: false },
|
||||
{ type: 'compositionstart', data: '' },
|
||||
{ type: 'compositionupdate', data: '글' }
|
||||
]
|
||||
|
||||
const openTerminals: Terminal[] = []
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function buildEvent(recorded: RecordedEvent): Event {
|
||||
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
|
||||
const keyboard = new KeyboardEvent(recorded.type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
isComposing: recorded.isComposing,
|
||||
key: recorded.key ?? ''
|
||||
})
|
||||
Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode })
|
||||
return keyboard
|
||||
}
|
||||
if (recorded.type === 'input' || recorded.type === 'beforeinput') {
|
||||
const input = new InputEvent(recorded.type, {
|
||||
bubbles: true,
|
||||
isComposing: recorded.isComposing
|
||||
})
|
||||
// happy-dom drops these from InputEventInit; Chromium supplies them.
|
||||
Object.defineProperty(input, 'inputType', { value: 'insertCompositionText' })
|
||||
Object.defineProperty(input, 'data', { value: recorded.data ?? null })
|
||||
Object.defineProperty(input, 'composed', { value: true })
|
||||
return input
|
||||
}
|
||||
const composition = new CompositionEvent(recorded.type, { bubbles: true })
|
||||
Object.defineProperty(composition, 'data', { value: recorded.data ?? '' })
|
||||
return composition
|
||||
}
|
||||
|
||||
type Rig = {
|
||||
compositionView: HTMLElement
|
||||
replay: (events: RecordedEvent[]) => Promise<void>
|
||||
terminal: Terminal
|
||||
}
|
||||
|
||||
function openTerminal(): Rig {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal({ cols: 80, rows: 24 })
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
const compositionView = container.querySelector<HTMLElement>('.composition-view')
|
||||
if (!textarea || !compositionView) {
|
||||
throw new Error('xterm did not create the helper textarea and composition view')
|
||||
}
|
||||
openTerminals.push(terminal)
|
||||
|
||||
const cell = (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
|
||||
}
|
||||
}
|
||||
)._core._renderService.dimensions.css.cell
|
||||
cell.width = CELL_WIDTH_PX
|
||||
cell.height = CELL_HEIGHT_PX
|
||||
terminal.onData((data) => terminal.write(data))
|
||||
|
||||
const replay = async (events: RecordedEvent[]): Promise<void> => {
|
||||
for (const recorded of events) {
|
||||
// Keys were physically separate, so each one began its own task.
|
||||
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
|
||||
await nextEventLoop()
|
||||
}
|
||||
if (recorded.data !== undefined && recorded.type !== 'compositionupdate') {
|
||||
textarea.value = recorded.data
|
||||
textarea.setSelectionRange(recorded.data.length, recorded.data.length)
|
||||
}
|
||||
textarea.dispatchEvent(buildEvent(recorded))
|
||||
}
|
||||
}
|
||||
|
||||
return { compositionView, replay, terminal }
|
||||
}
|
||||
|
||||
/** What the user sees: the class CSS keys `display: block` off, and the text inside it. */
|
||||
function displayedPreedit(view: HTMLElement): { preedit: string; shown: boolean } {
|
||||
return {
|
||||
preedit: (view.textContent ?? '').replaceAll('', ''),
|
||||
shown: view.classList.contains('active')
|
||||
}
|
||||
}
|
||||
|
||||
describe('preedit visibility across a composition the IME resumes', () => {
|
||||
beforeEach(() => {
|
||||
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// updateCompositionElements re-arms on a timer; let the pending one run before dispose.
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
while (openTerminals.length > 0) {
|
||||
openTerminals.pop()?.dispose()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('shows a preedit the IME resumes with an update and no second compositionstart', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay(RESUMED_WITHOUT_START)
|
||||
|
||||
expect(displayedPreedit(rig.compositionView)).toEqual({ preedit: '네', shown: true })
|
||||
})
|
||||
|
||||
it('shows a preedit the IME resumes after the Enter that committed the last syllable', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay(RESUMED_ACROSS_ENTER)
|
||||
|
||||
expect(displayedPreedit(rig.compositionView)).toEqual({ preedit: '제', shown: true })
|
||||
})
|
||||
|
||||
it('still shows a preedit the IME reopens with a compositionstart', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay(REOPENED_WITH_START)
|
||||
|
||||
expect(displayedPreedit(rig.compositionView)).toEqual({ preedit: '글', shown: true })
|
||||
})
|
||||
|
||||
it('hides the overlay once the composition ends and nothing resumes it', async () => {
|
||||
const rig = openTerminal()
|
||||
|
||||
await rig.replay([
|
||||
{ type: 'compositionstart', data: '' },
|
||||
{ type: 'compositionupdate', data: '한' },
|
||||
{ type: 'compositionend', data: '한' }
|
||||
])
|
||||
|
||||
expect(displayedPreedit(rig.compositionView).shown).toBe(false)
|
||||
})
|
||||
})
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Replays a recorded Windows/WSL 2-Set Korean capture and asserts the preedit stayed visible for
|
||||
// every composition update the IME actually emitted — 37 of them across 11 balanced sessions.
|
||||
//
|
||||
// It does NOT cover the resume-without-compositionstart ordering, and an earlier version of this
|
||||
// file wrongly claimed it did. The capture logs each event twice (a dispatch record and a batched
|
||||
// next-frame re-log); replaying both fabricates updates that appear to land after a session ended.
|
||||
// Filtered to dispatch records the capture holds zero resumes. The fixture is now filtered, so this
|
||||
// asserts real emissions only. The resume ordering is pinned synthetically in
|
||||
// terminal-ime-xterm-resumed-preedit-visibility.test.ts, which says so.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import trace from './__fixtures__/windows-wsl-hangul-resumed-preedit-trace.json'
|
||||
|
||||
type RecordedEvent = {
|
||||
type: string
|
||||
inputType?: string
|
||||
data?: string
|
||||
key?: string
|
||||
code?: string
|
||||
keyCode?: number
|
||||
isComposing?: boolean
|
||||
value?: string
|
||||
selectionStart?: number
|
||||
selectionEnd?: number
|
||||
}
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function buildEvent(recorded: RecordedEvent): Event {
|
||||
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
|
||||
const keyboard = new KeyboardEvent(recorded.type, {
|
||||
key: recorded.key,
|
||||
code: recorded.code,
|
||||
isComposing: recorded.isComposing,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode })
|
||||
return keyboard
|
||||
}
|
||||
if (recorded.type === 'input' || recorded.type === 'beforeinput') {
|
||||
const input = new InputEvent(recorded.type, {
|
||||
isComposing: recorded.isComposing,
|
||||
bubbles: true
|
||||
})
|
||||
// happy-dom drops these from InputEventInit; Chromium supplies them.
|
||||
Object.defineProperty(input, 'inputType', { value: recorded.inputType ?? '' })
|
||||
Object.defineProperty(input, 'data', { value: recorded.data ?? null })
|
||||
Object.defineProperty(input, 'composed', { value: true })
|
||||
return input
|
||||
}
|
||||
const composition = new CompositionEvent(recorded.type, { bubbles: true })
|
||||
Object.defineProperty(composition, 'data', { value: recorded.data ?? '' })
|
||||
return composition
|
||||
}
|
||||
|
||||
describe('Windows/WSL Korean — recorded composition updates keep the preedit visible', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('keeps the preedit visible for every recorded update', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
if (!textarea) {
|
||||
throw new Error('xterm helper textarea was not created')
|
||||
}
|
||||
|
||||
const events = trace.dom as RecordedEvent[]
|
||||
let compositionOpen = false
|
||||
const resumed: { data: string; shown: boolean }[] = []
|
||||
const shown: { data: string; visible: boolean }[] = []
|
||||
|
||||
for (const recorded of events) {
|
||||
if (recorded.type === 'keydown' || recorded.type === 'keyup') {
|
||||
await nextEventLoop()
|
||||
}
|
||||
if (recorded.value !== undefined) {
|
||||
textarea.value = recorded.value
|
||||
}
|
||||
if (recorded.selectionStart !== undefined && recorded.selectionEnd !== undefined) {
|
||||
textarea.setSelectionRange(recorded.selectionStart, recorded.selectionEnd)
|
||||
}
|
||||
textarea.dispatchEvent(buildEvent(recorded))
|
||||
|
||||
if (recorded.type === 'compositionstart') {
|
||||
compositionOpen = true
|
||||
} else if (recorded.type === 'compositionend') {
|
||||
compositionOpen = false
|
||||
} else if (recorded.type === 'compositionupdate' && recorded.data) {
|
||||
const view = terminal.element?.querySelector('.composition-view')
|
||||
const active = view?.classList.contains('active') === true
|
||||
shown.push({ data: recorded.data, visible: active })
|
||||
if (!compositionOpen) {
|
||||
resumed.push({ data: recorded.data, shown: active })
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.dispose()
|
||||
|
||||
// Guards against a fixture that silently stops covering anything.
|
||||
expect(shown).toHaveLength(37)
|
||||
expect(shown.filter((sample) => !sample.visible)).toEqual([])
|
||||
// The real capture never resumes a composition without a start; if it ever appears to, the
|
||||
// fixture has picked up the batched re-log again.
|
||||
expect(resumed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -90,10 +90,6 @@ import {
|
||||
createTerminalImePendingCandidateKeyReleases,
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease
|
||||
} from './terminal-ime-candidate-key-release-guard'
|
||||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
getMacNativeTextInputSourceTracker
|
||||
} from './terminal-ime-input-source'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
import {
|
||||
shouldBypassXtermKeyboardEvent,
|
||||
@@ -912,7 +908,6 @@ export function useTerminalPaneLifecycle({
|
||||
const linuxImeCandidateState = isLinux
|
||||
? installTerminalImeLinuxCandidateState(pane.terminal.element)
|
||||
: null
|
||||
const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null
|
||||
const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
|
||||
imeCompositionDisposablesRef.current.set(pane.id, {
|
||||
dispose: () => {
|
||||
@@ -920,15 +915,14 @@ export function useTerminalPaneLifecycle({
|
||||
linuxImeCandidateState?.dispose()
|
||||
}
|
||||
})
|
||||
// Why: only known macOS native text paths (physical CJK/Vietnamese IME) need the keydown bypass; synthetic Unicode lacks physical key identity.
|
||||
// Why: macOS commits an input source's substituted text through the input event alone, so printable keydowns must not reach xterm's encoder.
|
||||
const imeNativeTextForwarder = isMac
|
||||
? installTerminalImeNativeTextForwarder({
|
||||
terminalElement: pane.terminal.element,
|
||||
isComposing: () => imeCompositionTracker.isActive(),
|
||||
sendInput: (data) => pane.terminal.input(data),
|
||||
getInputSourceFeatures: () =>
|
||||
macNativeTextInputSourceTracker?.getFeatures() ??
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES
|
||||
getKittyKeyboardFlags: () =>
|
||||
paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0
|
||||
})
|
||||
: {
|
||||
claimKeyEvent: () => false,
|
||||
|
||||
@@ -119,49 +119,23 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => {
|
||||
expect(shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC' }), opts)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves physical Backslash to native keypress when kitty reporting is inactive', () => {
|
||||
// Why: third-party IMEs outside the forwarder's CJK allowlist (Qingg, #10896)
|
||||
// need Chromium's text pipeline to turn this key into 、 rather than \.
|
||||
for (const type of ['keydown', 'keyup']) {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(
|
||||
event({ type, key: '\\', code: 'Backslash', keyCode: 220 }),
|
||||
{ ...noSel, kittyKeyboardFlags: 0 }
|
||||
)
|
||||
).toBe(true)
|
||||
it('no longer special-cases Backslash — the native-text forwarder owns it', () => {
|
||||
// Why: this policy carried a `code === 'Backslash'` bypass because the old
|
||||
// forwarder only claimed keys for input sources on a hardcoded allowlist.
|
||||
// The structural claim covers every printable key, so the exception is gone
|
||||
// and the physical key is no longer named anywhere in this file.
|
||||
for (const type of ['keydown', 'keyup', 'keypress']) {
|
||||
for (const kittyKeyboardFlags of [0, 1]) {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ type, key: '\\', code: 'Backslash' }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not bypass the native Backslash keypress', () => {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(
|
||||
event({ type: 'keypress', key: '\\', code: 'Backslash', keyCode: 0x3001 }),
|
||||
{ ...noSel, kittyKeyboardFlags: 0 }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps modified or kitty-reported Backslash in xterm', () => {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: '|', code: 'Backslash', shiftKey: true }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: '\\', code: 'Backslash', ctrlKey: true }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: '\\', code: 'Backslash' }), {
|
||||
...noSel,
|
||||
kittyKeyboardFlags: 1
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('bubbles Shift+non-ASCII printable text so the active keyboard layout wins', () => {
|
||||
expect(
|
||||
shouldBypassXtermKeyboardEvent(event({ key: 'Ф', code: 'KeyA', shiftKey: true }), opts)
|
||||
|
||||
@@ -84,33 +84,6 @@ function isSingleNonAsciiPrintableText(key: string): boolean {
|
||||
return codePoint !== undefined && codePoint >= 0x80
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: kept from #13128 when the rest of it was reverted. The native-text
|
||||
* forwarder only claims keys for input sources in its hardcoded CJK allowlist,
|
||||
* so third-party IMEs off that list (Qingg, issue #10896) never get their
|
||||
* backslash-position key claimed and xterm sends the raw `\` instead of `、`.
|
||||
* Bypassing keydown lets Chromium's text pipeline produce the layout character.
|
||||
* Scoped to the bare backslash key: broadening this to all non-ASCII text would
|
||||
* race the forwarder and double-send.
|
||||
*/
|
||||
export function shouldBypassXtermForMacNativeText(
|
||||
event: XtermBypassEvent,
|
||||
isMac: boolean,
|
||||
kittyKeyboardActive = false
|
||||
): boolean {
|
||||
if (
|
||||
!isMac ||
|
||||
!isXtermHandledKeyEvent(event.type) ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return !kittyKeyboardActive && event.code === 'Backslash'
|
||||
}
|
||||
|
||||
function isXtermHandledKeyEvent(type: string): boolean {
|
||||
return type === 'keydown' || type === 'keyup'
|
||||
}
|
||||
@@ -249,9 +222,6 @@ export function shouldBypassXtermKeyboardEvent(
|
||||
}
|
||||
|
||||
const { isMac, hasSelection } = options
|
||||
if (shouldBypassXtermForMacNativeText(event, isMac, (options.kittyKeyboardFlags ?? 0) !== 0)) {
|
||||
return true
|
||||
}
|
||||
const platformModifierHeld = isMac
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey
|
||||
|
||||
@@ -10421,13 +10421,13 @@
|
||||
"title": "Artifacts",
|
||||
"description": "Share HTML and Markdown files with your team and manage their public links.",
|
||||
"howToTitle": "How to use Artifacts",
|
||||
"howToDescription": "Ask your agent to share an HTML or Markdown file. Orca handles the upload with your account.",
|
||||
"shareStepTitle": "Ask your agent to share it",
|
||||
"shareStepDescription": "For example: “Share this HTML mock as an artifact.”",
|
||||
"linkStepTitle": "Share the public link",
|
||||
"linkStepDescription": "Your agent returns a link that anyone with the URL can view.",
|
||||
"howToDescription": "Publish HTML or Markdown files as public links, then share them with your team.",
|
||||
"shareStepTitle": "Choose a file to share",
|
||||
"shareStepDescription": "Open an HTML or Markdown file and select Share as artifact, or ask an agent to share it.",
|
||||
"linkStepTitle": "Copy the public link",
|
||||
"linkStepDescription": "After publishing, copy the link and send it to your team.",
|
||||
"manageStepTitle": "Manage it in Orca",
|
||||
"manageStepDescription": "Open Artifacts from the sidebar to revisit or delete links owned by your account.",
|
||||
"manageStepDescription": "Open Artifacts from the sidebar to preview or remove links.",
|
||||
"openArtifacts": "Open Artifacts",
|
||||
"openArtifactsDescription": "View and delete links shared through your account.",
|
||||
"showButton": "Show Artifacts Button",
|
||||
@@ -10436,20 +10436,20 @@
|
||||
"signInTitle": "Sign in to share artifacts",
|
||||
"signInDescription": "Use your Orca account to upload artifacts and manage their public links.",
|
||||
"signInAgain": "Sign in again",
|
||||
"enableStepTitle": "Allow publishing first",
|
||||
"enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and turn publishing on. Until then every share fails with artifact_sharing_disabled.",
|
||||
"enableStepDescription": "Turn on “Allow publishing public artifact links” above. Until then every share fails with artifact_sharing_disabled.",
|
||||
"enableStepTitle": "Enable artifact sharing",
|
||||
"enableStepWebDescription": "Open Settings → Artifacts in the Orca desktop app on the host device and enable publishing.",
|
||||
"enableStepDescription": "Turn on “Allow publishing public artifact links” above.",
|
||||
"allowPublishing": "Allow publishing public artifact links",
|
||||
"allowPublishingWebDescription": "Desktop only. This capability is granted on the device running Orca — open Settings → Artifacts there to change it. Shown read-only here so it matches what that device enforces.",
|
||||
"allowPublishingDescription": "Off by default. When on, anything running on this device — your agents and the orca CLI — can upload HTML and Markdown files to your Orca account and mint links anyone with the URL can open. Turning it off does not delete existing links — remove those from Artifacts.",
|
||||
"howToDescriptionDisabled": "Publishing is off, so nothing on this device can create a public link yet. Allow it first, then ask your agent to share an HTML or Markdown file.",
|
||||
"allowPublishingSearchDescription": "Let agents and the orca CLI upload files to your Orca account and mint public links."
|
||||
"allowPublishingWebDescription": "Desktop only. Open Settings → Artifacts on the host device to change this setting.",
|
||||
"allowPublishingDescription": "Publish HTML and Markdown files as links anyone with the URL can open. Existing links remain until you delete them from Artifacts.",
|
||||
"howToDescriptionDisabled": "Enable artifact sharing above to publish HTML or Markdown files as public links.",
|
||||
"allowPublishingSearchDescription": "Allow Orca to publish HTML and Markdown files as public links."
|
||||
},
|
||||
"orcaAccount": {
|
||||
"connected": "Connected",
|
||||
"reconnectRequired": "Your session expired. Sign in again to use cloud features.",
|
||||
"unavailable": "Orca sign-in is unavailable in this build.",
|
||||
"signedOut": "Sign in to use Artifacts and Orca Relay.",
|
||||
"signedOut": "Sign in to extend Orca with cloud features, including Artifacts and Orca Relay.",
|
||||
"checking": "Checking account status…",
|
||||
"account": "Orca account",
|
||||
"signOut": "Sign out",
|
||||
@@ -14957,14 +14957,14 @@
|
||||
"signingIn": "Signing in…",
|
||||
"signIn": "Sign in to Orca",
|
||||
"empty": "No shared artifacts",
|
||||
"emptyCopy": "Ask your agent to share an HTML or Markdown file, and it will appear here.",
|
||||
"emptyCopy": "Open an HTML or Markdown file and select Share as artifact, or ask your agent to share it.",
|
||||
"openArtifact": "Open artifact",
|
||||
"deleteArtifact": "Delete artifact",
|
||||
"moreAvailable": "More artifacts are available",
|
||||
"moreAvailableCopy": "Load the next page to continue.",
|
||||
"loadMoreFailed": "Could not load more artifacts.",
|
||||
"publishingOff": "Publishing is turned off",
|
||||
"publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then ask your agent to share an HTML or Markdown file.",
|
||||
"publishingOffCopy": "Nothing on this device can create a public artifact link yet. Allow publishing in Settings → Artifacts, then share from an open HTML or Markdown file or ask your agent.",
|
||||
"openArtifactsSettings": "Open Settings → Artifacts",
|
||||
"loadedCountMore": "{{count}} loaded · more available",
|
||||
"loadedCount": "{{count}} shared",
|
||||
@@ -14998,7 +14998,45 @@
|
||||
"updatedRecently": "recently",
|
||||
"expiryUnknown": "Expiry unknown",
|
||||
"expired": "Link expired",
|
||||
"expires": "Link expires {{when}}"
|
||||
"expires": "Link expires {{when}}",
|
||||
"ArtifactPublishButton": {
|
||||
"a4a49da6af": "Share as artifact",
|
||||
"confirmTitle": "Share as artifact",
|
||||
"confirmDescription": "This publishes the current file at a link anyone with the URL can view.",
|
||||
"accountTitle": "Orca account",
|
||||
"accountDescription": "Sign in to create and manage this link.",
|
||||
"signingIn": "Signing in…",
|
||||
"signInAgain": "Sign in again",
|
||||
"signIn": "Sign in",
|
||||
"publishingOffTitle": "Artifact sharing is off",
|
||||
"publishingOffDescription": "Learn about public links and enable sharing in Settings.",
|
||||
"openSettings": "Open Artifacts settings",
|
||||
"sharing": "Sharing…",
|
||||
"sharePublicLink": "Share public link",
|
||||
"publishedDescription": "Anyone with this link can view the shared file.",
|
||||
"checkingLink": "Checking for an existing link…",
|
||||
"checkFailed": "Could not check for an existing link.",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"artifact-publish-flow": {
|
||||
"9a078a0c65": "Artifact sharing is unavailable",
|
||||
"bba20daa6d": "Sign in to Orca and try again.",
|
||||
"54b1805328": "Could not share artifact",
|
||||
"430019efd0": "Artifact shared",
|
||||
"2fc727c831": "Artifact updated",
|
||||
"5cb4f5ec36": "Copy link",
|
||||
"fbb5018602": "This file is empty.",
|
||||
"6112db5a1c": "Artifacts shared from Orca must be smaller than 800 KB.",
|
||||
"e2ed5acd8c": "Orca couldn't read this file. Open it from a workspace and try again.",
|
||||
"6d475e9b25": "Only local HTML and Markdown files can be shared as artifacts.",
|
||||
"29a406be09": "Artifacts must contain text."
|
||||
},
|
||||
"ArtifactPublishedLinkPanel": {
|
||||
"copyLink": "Copy link",
|
||||
"openLink": "Open link",
|
||||
"updating": "Updating…",
|
||||
"update": "Update shared content"
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
||||
+2754
-2754
File diff suppressed because it is too large
Load Diff
@@ -2783,7 +2783,7 @@
|
||||
"agent": {
|
||||
"row": {
|
||||
"ad991ece5c": "智能体的窗格不再可用。",
|
||||
"090d607412": "陈旧智能体行-{{value0}}"
|
||||
"090d607412": "stale-agent-row-{{value0}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const REMOTE_ARTIFACT_INPUT_ENV = 'ORCA_REMOTE_ARTIFACT_INPUT'
|
||||
|
||||
export function sshArtifactSourceKey(targetId: string, sourceKey: string): string {
|
||||
return JSON.stringify(['ssh', targetId, sourceKey])
|
||||
}
|
||||
|
||||
export type RemoteArtifactInput = {
|
||||
sourceKey: string
|
||||
fileName: string
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024
|
||||
|
||||
export function artifactWriteRequestByteLength(request: ArtifactWriteRequest): number {
|
||||
return new TextEncoder().encode(JSON.stringify(request)).byteLength
|
||||
}
|
||||
|
||||
export type ArtifactMetadata = {
|
||||
version: 1
|
||||
slug: string
|
||||
@@ -34,6 +38,15 @@ export type ArtifactWriteRequest = {
|
||||
authToken?: string
|
||||
}
|
||||
|
||||
export type ArtifactPublishResult = {
|
||||
change: 'created' | 'updated'
|
||||
item: ArtifactListItem
|
||||
}
|
||||
|
||||
export type ArtifactPublishedLink = {
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export type ArtifactCloudOptions = {
|
||||
apiUrl?: string
|
||||
authToken?: string
|
||||
|
||||
@@ -32,7 +32,14 @@ process.stdin.on('data', (chunk) => {
|
||||
pending = Buffer.concat([pending, Buffer.from(chunk)])
|
||||
let newlineIndex = pending.indexOf(0x0a)
|
||||
while (newlineIndex >= 0) {
|
||||
const line = pending.subarray(0, newlineIndex + 1)
|
||||
// Why: a Unix pty's line discipline turns the terminal's CR into a bare LF, but Windows
|
||||
// ConPTY hands the reader CRLF. Drop the CR so a recorded line-feed expectation holds on
|
||||
// every substrate; the IME payload bytes ahead of it are compared unchanged.
|
||||
const rawLine = pending.subarray(0, newlineIndex + 1)
|
||||
const hasCarriageReturn = rawLine.length > 1 && rawLine[rawLine.length - 2] === 0x0d
|
||||
const line = hasCarriageReturn
|
||||
? Buffer.concat([rawLine.subarray(0, rawLine.length - 2), Buffer.from([0x0a])])
|
||||
: rawLine
|
||||
pending = pending.subarray(newlineIndex + 1)
|
||||
receivedLineCount += 1
|
||||
process.stdout.write(resultPrefix + ':' + receivedLineCount + ':' + line.toString('hex') + '\\n')
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Headless end-to-end coverage for a macOS system-wide key remap reaching the terminal.
|
||||
*
|
||||
* macOS lets a user rewrite what a physical key inserts by declaring it in
|
||||
* `~/Library/KeyBindings/DefaultKeyBinding.dict`. The reported case is a Korean-source user who
|
||||
* remaps the ₩ key back to a backquote:
|
||||
*
|
||||
* { "₩" = ("insertText:", "`"); "~₩" = ("insertText:", "₩"); }
|
||||
*
|
||||
* The remap is honoured by every other app on the system — browsers, native apps, and this app's
|
||||
* own text inputs — and ignored only inside the terminal, which delivers the raw layout character
|
||||
* to the PTY. The cause is the same one the rest of this suite is about: the substitution lives in
|
||||
* the text system's `insertText:` callback, so a terminal that produces its byte from the
|
||||
* **keydown** and then calls `preventDefault()` cancels the pipeline before the substitution can
|
||||
* arrive.
|
||||
*
|
||||
* Byte-wise this is the full-width-punctuation shape with a different producer — a keydown still
|
||||
* carrying the physical layout character, no composition session anywhere, and the real character
|
||||
* arriving only in the following `insertText` input event — so it is dispatched through the same
|
||||
* helper and pinned to the same macOS ownership policy.
|
||||
*
|
||||
* Two layout variants of the same physical key are covered, and the second is why this spec is not
|
||||
* redundant. Korean layouts disagree about what the backquote position produces: 두벌식 and
|
||||
* 세벌식 390 put ₩ there, 세벌식 최종 puts `*`. A character allowlist can only honour the remap for
|
||||
* the characters someone remembered to list, so it fixes the reported ₩ and silently drops the
|
||||
* identical remap for a 세벌식 최종 user. Deciding on the event's shape instead of on the character
|
||||
* covers both, which is what the variant arm pins.
|
||||
*
|
||||
* The paired control matters as much as the positive case. A "fix" that special-cased the layout
|
||||
* character into a backquote would satisfy the remap arm and be wrong for every Korean user who has
|
||||
* no remap, so the unremapped key is asserted to still deliver its own character — once in the
|
||||
* shape macOS produces with no remap at all (the keydown carries the glyph), and once through the
|
||||
* remap path with the substitution set to that character itself, which is the config's second line.
|
||||
*/
|
||||
import type { CDPSession } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
|
||||
import { readTerminalImeBoundaryTrace } from './terminal-ime-boundary-probe'
|
||||
import {
|
||||
dispatchImeRewrittenPrintableKey,
|
||||
dispatchImeSubstitutedTextKey,
|
||||
dispatchPlainEnter,
|
||||
type ImeKeyIdentity
|
||||
} from './terminal-ime-cdp-composition'
|
||||
import {
|
||||
createTerminalImeByteReader,
|
||||
removeTerminalImeByteReader,
|
||||
startTerminalImeByteReader,
|
||||
waitForTerminalImeBytes
|
||||
} from './terminal-ime-byte-reader'
|
||||
import { applyImePlatformPolicy, expectImePlatformPolicy } from './terminal-ime-platform-policy'
|
||||
|
||||
const BACKQUOTE = '`'
|
||||
|
||||
/** What the physical backquote position inserts, per Korean layout, before any remap. */
|
||||
const LAYOUT_VARIANTS = [
|
||||
{ label: '두벌식', character: '₩' },
|
||||
{ label: '세벌식 최종', character: '*' }
|
||||
] as const
|
||||
|
||||
type RemapArm = {
|
||||
name: string
|
||||
slug: string
|
||||
/** What the text system commits for the keystroke. */
|
||||
committed: (layoutCharacter: string) => string
|
||||
dispatch: (session: CDPSession, key: ImeKeyIdentity, committed: string) => Promise<void>
|
||||
}
|
||||
|
||||
const REMAP_ARMS: readonly RemapArm[] = [
|
||||
{
|
||||
// The reported bug. The keydown carries the layout character and the backquote exists only in
|
||||
// the `insertText` event the remap produces, so anything that emits bytes from the keydown
|
||||
// sends the layout character and the user's system-wide remap is dropped at the terminal.
|
||||
name: 'a system remap rewrites it to a backquote',
|
||||
slug: 'remapped-to-backquote',
|
||||
committed: () => BACKQUOTE,
|
||||
dispatch: (session, key, committed) => dispatchImeSubstitutedTextKey(session, key, committed)
|
||||
},
|
||||
{
|
||||
// Control. With no remap the text system commits the layout character itself and macOS puts it
|
||||
// straight on the keydown. A rewrite that mapped the key to a backquote unconditionally fails
|
||||
// here.
|
||||
name: 'no remap is installed',
|
||||
slug: 'unremapped',
|
||||
committed: (layoutCharacter) => layoutCharacter,
|
||||
dispatch: (session, key) => dispatchImeRewrittenPrintableKey(session, key)
|
||||
},
|
||||
{
|
||||
// Control on the substitution path itself, which is the config's second line: the remap is
|
||||
// present and commits the layout character. Same `insertText` route as the first arm, opposite
|
||||
// expected byte, so a fix that keys on the route rather than on what was committed cannot pass
|
||||
// both.
|
||||
name: 'a system remap substitutes it for itself',
|
||||
slug: 'remapped-to-itself',
|
||||
committed: (layoutCharacter) => layoutCharacter,
|
||||
dispatch: (session, key, committed) => dispatchImeSubstitutedTextKey(session, key, committed)
|
||||
}
|
||||
]
|
||||
|
||||
test.describe('Terminal macOS system key remap', () => {
|
||||
for (const layout of LAYOUT_VARIANTS) {
|
||||
// keyCode 192 is the backquote position itself, unchanged by which character the layout puts
|
||||
// on it — the remap targets the key, not the character.
|
||||
const layoutKey: ImeKeyIdentity = { key: layout.character, code: 'Backquote', keyCode: 192 }
|
||||
|
||||
for (const arm of REMAP_ARMS) {
|
||||
const committed = arm.committed(layout.character)
|
||||
const forbidden = committed === BACKQUOTE ? layout.character : BACKQUOTE
|
||||
|
||||
test(`sends ${committed} for the ${layout.label} backquote key when ${arm.name}`, async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await applyImePlatformPolicy(orcaPage, 'mac')
|
||||
await expectImePlatformPolicy(orcaPage, 'mac')
|
||||
const arena = await openTerminalImePaneArena(orcaPage)
|
||||
const reader = createTerminalImeByteReader(testRepoPath, 1)
|
||||
let completed = false
|
||||
try {
|
||||
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
|
||||
await arm.dispatch(arena.session, layoutKey, committed)
|
||||
await orcaPage.waitForTimeout(60)
|
||||
await dispatchPlainEnter(arena.session)
|
||||
|
||||
const trace = await readTerminalImeBoundaryTrace(orcaPage)
|
||||
|
||||
// A remap is not an IME. Nothing here may open a composition session, and a spec that
|
||||
// accidentally replayed one would be testing a path the suite already covers.
|
||||
const compositionEvents = trace.dom
|
||||
.map((event) => event.type)
|
||||
.filter((type) => type.startsWith('composition'))
|
||||
expect(compositionEvents).toEqual([])
|
||||
// Pins the producer: the physical backquote position, carrying the layout's character.
|
||||
// What was committed is asserted on the wire below rather than on the DOM, because the
|
||||
// commit's `input` event is consumed before this probe sees it once a forwarder owns it.
|
||||
const keydowns = trace.dom.filter(
|
||||
(event) => event.type === 'keydown' && event.code === 'Backquote'
|
||||
)
|
||||
expect(keydowns).toHaveLength(1)
|
||||
expect(keydowns[0].key).toBe(layout.character)
|
||||
|
||||
const sent = trace.onData.join('')
|
||||
expect(sent, `${forbidden} reached the PTY instead of ${committed}`).not.toContain(
|
||||
forbidden
|
||||
)
|
||||
expect(sent).toBe(`${committed}\r`)
|
||||
|
||||
const received = await waitForTerminalImeBytes(orcaPage, reader)
|
||||
expect(received).toEqual([Buffer.from(`${committed}\n`).toString('hex')])
|
||||
completed = true
|
||||
} finally {
|
||||
await closeTerminalImePaneArena(arena, testInfo, `${arm.slug}-${layout.label}`, !completed)
|
||||
removeTerminalImeByteReader(reader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user