mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge branch 'main' into conflict-resolve-button-not-wired-up
This commit is contained in:
@@ -883,12 +883,32 @@ jobs:
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Why: Some hosted Windows images start without PSGallery registered.
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
# Why: on some hosted Windows images `Register-PSRepository -Default`
|
||||
# fails inside the legacy nuget.exe provider with "Missing option value
|
||||
# for: '-source'", so PSGallery is never registered and the install
|
||||
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
|
||||
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
|
||||
# avoids that code path, so prefer it and fall back to PowerShellGet only
|
||||
# when it is absent.
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
} else {
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
}
|
||||
|
||||
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
|
||||
@@ -917,7 +937,11 @@ jobs:
|
||||
}
|
||||
|
||||
try {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
if ($useResourceGet) {
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
break
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -256,6 +256,10 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
|
||||
@@ -1559,12 +1559,6 @@
|
||||
"auto.components.onboarding.ThemeStep.94b9dc561d": {
|
||||
"ko": "설정 -> Terminal"
|
||||
},
|
||||
"auto.components.pet.PetOverlay.4712d196c6": {
|
||||
"ko": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
|
||||
},
|
||||
"auto.components.pet.PetOverlay.de932b0e8f": {
|
||||
"ko": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }"
|
||||
},
|
||||
"auto.components.repo.NestedRepoChecklist.ea54c7bf8f": {
|
||||
"ko": "/"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
|
||||
<title>downloads: 3.4m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.5m">
|
||||
<title>downloads: 3.5m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
|
||||
<text x="93.5" y="14">3.4m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.5m</text>
|
||||
<text x="93.5" y="14">3.5m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrelas no GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Total de downloads em todas as versões" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licença" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Entre no Discord do Orca" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Siga o Orca no X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatíveis: macOS, Windows e Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>O orquestrador de IA para builders 100x.</strong><br/>
|
||||
Rode Codex, ClaudeCode, OpenCode ou Pi lado a lado — cada um em seu próprio worktree, acompanhado em um só lugar.
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Baixar o Orca</ins></a></h3>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/readme-hero.jpg" alt="App desktop do Orca executando agentes em worktrees paralelos, com o app companion móvel do Orca no canto" width="960" />
|
||||
</p>
|
||||
|
||||
## Recursos
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Companion móvel
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop com o app companion móvel" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Worktrees paralelos
|
||||
|
||||
Envie um mesmo prompt para cinco agentes, cada um em seu próprio worktree git isolado — compare os resultados e faça merge do vencedor.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/model/worktrees)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestração de worktrees paralelos" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Terminais divididos
|
||||
|
||||
Terminais no nível do Ghostty com renderização WebGL, divisões infinitas e scrollback que sobrevive a reinicializações.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/terminal)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminais divididos" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Modo Design
|
||||
|
||||
Clique em qualquer elemento de UI em uma janela real do Chromium para enviar HTML, CSS e uma captura recortada direto para o prompt do seu agente.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado e Modo Design" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### GitHub e Linear nativos
|
||||
|
||||
Navegue por PRs, issues e quadros de projeto dentro do app — abra um worktree a partir de qualquer tarefa e revise sem trocar de contexto.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Fluxos de trabalho de tarefas do GitHub e Linear no Orca" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Worktrees por SSH
|
||||
|
||||
Execute agentes em uma máquina remota potente com edição completa de arquivos, git e terminais — com reconexão automática e encaminhamento de portas incluídos.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Anotar diffs de IA
|
||||
|
||||
Deixe comentários em qualquer linha de diff e envie-os de volta ao agente — revise, edite e faça commit sem sair do Orca.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs gerados por IA" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Arraste arquivos para agentes
|
||||
|
||||
O editor do VS Code com salvamento automático em todos os lugares — arraste arquivos ou imagens direto para o prompt de um agente.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arraste arquivos e imagens para o prompt de um agente" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
Agentes também controlam o Orca — automatize qualquer fluxo de trabalho com `orca worktree create`, `snapshot`, `click` e `fill`.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatize o Orca pela CLI" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Também incluído:**
|
||||
|
||||
- **[Abertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Pesquise entre worktrees, arquivos, agentes, comandos e contexto do repositório sem sair do seu fluxo.
|
||||
- **[Troca de contas e acompanhamento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Veja o uso de Claude e Codex, os reinícios de limites e troque contas instantaneamente sem fazer login de novo.
|
||||
- **[Prévias ricas do repositório](https://www.onorca.dev/docs/editing/markdown)** — Pré-visualize Markdown, imagens, PDFs e documentos do repositório no workspace.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deixe agentes operarem apps de desktop e UI visível quando um fluxo de trabalho precisa de interação real.
|
||||
- **[Notificações e estado de não lido](https://www.onorca.dev/docs/notifications)** — Saiba quando um agente termina ou precisa de atenção, depois marque conversas como não lidas para voltar depois.
|
||||
- **E muito, muito mais** — lançamos novidades todos os dias, então esta lista vive atrasada. O [changelog](https://github.com/stablyai/orca/releases) é a lista real de recursos.
|
||||
|
||||
---
|
||||
|
||||
## Agentes compatíveis
|
||||
|
||||
Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Logotipo do Claude Code" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Logotipo do Codex" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logotipo do Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logotipo do OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Logotipo do Antigravity" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logotipo do Pi" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="Logotipo do oh-my-pi" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Logotipo do Hermes Agent" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Logotipo do Devin" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Logotipo do Goose" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Logotipo do Auggie" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Logotipo do Autohand Code" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Logotipo do Charm" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Logotipo do Cline" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Logotipo do Codebuff" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Logotipo do Command Code" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Logotipo do Continue" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Logotipo do Droid" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Logotipo do Kilocode" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Logotipo do Kimi" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Logotipo do Kiro" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Logotipo do Mistral Vibe" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Logotipo do Qwen Code" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Logotipo do Rovo Dev" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ qualquer agente CLI</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Instalação
|
||||
|
||||
### Desktop — macOS, Windows, Linux
|
||||
|
||||
- **[Baixe em onOrca.dev](https://onorca.dev/download)**
|
||||
- Ou baixe um build diretamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos os builds](https://github.com/stablyai/orca/releases/latest)
|
||||
- Rodando `orca serve` em um servidor Linux headless? Veja o [guia de servidor Linux headless](../reference/headless-linux-server.md).
|
||||
|
||||
_Ou por um gerenciador de pacotes:_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
|
||||
# Arch Linux (AUR) — ou stably-orca-git para compilar a partir do código-fonte
|
||||
yay -S stably-orca-bin
|
||||
```
|
||||
|
||||
### Companion móvel — iOS, Android
|
||||
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## Comunidade e suporte
|
||||
|
||||
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
|
||||
- **WeChat:** Escaneie o QR code para entrar na comunidade.
|
||||
|
||||
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
|
||||
|
||||
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
|
||||
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
|
||||
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
|
||||
|
||||
---
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
Quer contribuir ou rodar localmente? Veja nosso guia [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
|
||||
|
||||
<a href="https://github.com/stablyai/orca/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contribuidores do Orca" />
|
||||
</a>
|
||||
|
||||
## Licença
|
||||
|
||||
Orca é livre e de código aberto sob a [Licença MIT](../../LICENSE).
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.122-rc.2",
|
||||
"version": "1.4.123-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -348,6 +348,70 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches due scheduled automations headlessly', async () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const beforeRunAt = new Date(2026, 4, 13, 8, 59).getTime()
|
||||
const scheduledRunAt = new Date(2026, 4, 13, 9, 0).getTime()
|
||||
const afterRunAt = new Date(2026, 4, 13, 9, 1).getTime()
|
||||
const nextRunAt = new Date(2026, 4, 14, 9, 0).getTime()
|
||||
|
||||
vi.setSystemTime(beforeRunAt)
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Morning check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone,
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date(2026, 4, 12, 0, 0).getTime()
|
||||
})
|
||||
|
||||
const headlessDispatcher = vi.fn().mockResolvedValue({
|
||||
workspaceId: 'wt1',
|
||||
workspaceDisplayName: 'Morning check',
|
||||
terminalSessionId: 'tab-1',
|
||||
terminalPaneKey: 'pane-1',
|
||||
terminalPtyId: 'pty-1'
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher
|
||||
})
|
||||
|
||||
try {
|
||||
vi.setSystemTime(afterRunAt)
|
||||
service.start()
|
||||
await vi.waitFor(() => expect(headlessDispatcher).toHaveBeenCalledTimes(1))
|
||||
const run = store.listAutomationRuns(automation.id)[0]
|
||||
expect(run?.status).toBe('dispatched')
|
||||
expect(run?.scheduledFor).toBe(scheduledRunAt)
|
||||
expect(headlessDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automation: expect.objectContaining({ id: automation.id }) })
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(store.listAutomations().find((entry) => entry.id === automation.id)?.nextRunAt).toBe(
|
||||
nextRunAt
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
service.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches provider usage when a completed run can be attributed', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -69,7 +69,9 @@ export class AutomationService {
|
||||
this.timer = setInterval(() => {
|
||||
void this.evaluateDueRuns()
|
||||
}, this.tickMs)
|
||||
if (this.rendererReady) {
|
||||
// Why: headless serve never gets a renderer-ready IPC, but due runs still
|
||||
// need the same startup catch-up pass desktop gets after renderer attach.
|
||||
if (this.rendererReady || this.headlessDispatcher) {
|
||||
void this.evaluateDueRuns()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +433,26 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
return preventDefault
|
||||
}
|
||||
|
||||
function triggerZoomChanged(direction: 'in' | 'out' | 'reset'): ReturnType<typeof vi.fn> {
|
||||
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'zoom-changed')?.[1] as
|
||||
| ((event: Electron.Event, direction: 'in' | 'out' | 'reset') => void)
|
||||
| undefined
|
||||
expect(handler).toBeTypeOf('function')
|
||||
const preventDefault = vi.fn()
|
||||
handler!({ preventDefault } as unknown as Electron.Event, direction)
|
||||
return preventDefault
|
||||
}
|
||||
|
||||
function triggerBeforeMouse(mouse: Electron.MouseInputEvent): ReturnType<typeof vi.fn> {
|
||||
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-mouse-event')?.[1] as
|
||||
| ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void)
|
||||
| undefined
|
||||
expect(handler).toBeTypeOf('function')
|
||||
const preventDefault = vi.fn()
|
||||
handler!({ preventDefault } as unknown as Electron.Event, mouse)
|
||||
return preventDefault
|
||||
}
|
||||
|
||||
function triggerGuestBlur(): void {
|
||||
const handler = guestOnMock.mock.calls.find((call) => call[0] === 'blur')?.[1] as
|
||||
| (() => void)
|
||||
@@ -488,7 +508,7 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards app zoom shortcuts from focused guest pages', () => {
|
||||
it('forwards browser page zoom shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
@@ -516,12 +536,12 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
expect(numpadSubtractPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(resetPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(repeatPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'terminal:zoom', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'terminal:zoom', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'terminal:zoom', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'terminal:zoom', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'terminal:zoom', 'reset')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'terminal:zoom', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:zoomBrowserPage', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:zoomBrowserPage', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:zoomBrowserPage', 'reset')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
|
||||
it('forwards browser history shortcuts from focused guest pages', () => {
|
||||
@@ -600,7 +620,73 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
|
||||
expect(defaultPreventDefault).not.toHaveBeenCalled()
|
||||
expect(customPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('terminal:zoom', 'in')
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
|
||||
it('forwards native guest zoom commands to browser page zoom when default zoom keys are bound', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
|
||||
const zoomOutPreventDefault = triggerZoomChanged('out')
|
||||
const zoomInPreventDefault = triggerZoomChanged('in')
|
||||
const resetPreventDefault = triggerZoomChanged('reset')
|
||||
|
||||
expect(zoomOutPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(zoomInPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(resetPreventDefault).not.toHaveBeenCalled()
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'out')
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
|
||||
it('does not double-forward ctrl wheel when Electron also emits a native zoom command', () => {
|
||||
const guest = makeGuest()
|
||||
|
||||
setupGuestMouseWheelZoomForwarding({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
|
||||
const wheelPreventDefault = triggerBeforeMouse({
|
||||
type: 'mouseWheel',
|
||||
x: 0,
|
||||
y: 0,
|
||||
deltaY: -120,
|
||||
modifiers: ['ctrl']
|
||||
} as Electron.MouseWheelInputEvent)
|
||||
const zoomCommandPreventDefault = triggerZoomChanged('in')
|
||||
|
||||
expect(wheelPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(zoomCommandPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:zoomBrowserPage', 'in')
|
||||
})
|
||||
|
||||
it('ignores native guest zoom commands when matching default zoom keys are unbound', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer(),
|
||||
getKeybindings: () => ({
|
||||
'zoom.in': ['Mod+Alt+Z'],
|
||||
'zoom.out': []
|
||||
})
|
||||
})
|
||||
|
||||
const zoomOutPreventDefault = triggerZoomChanged('out')
|
||||
const zoomInPreventDefault = triggerZoomChanged('in')
|
||||
|
||||
expect(zoomOutPreventDefault).not.toHaveBeenCalled()
|
||||
expect(zoomInPreventDefault).not.toHaveBeenCalled()
|
||||
expect(rendererSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards double-tap window shortcuts from focused guest pages', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
isRecentTabSwitcherCommitRelease,
|
||||
matchesRecentTabSwitcherChord,
|
||||
nativeZoomCommandMatchesKeybindings,
|
||||
resolveWindowShortcutAction,
|
||||
type WindowShortcutInput
|
||||
} from '../../shared/window-shortcut-policy'
|
||||
@@ -29,6 +30,38 @@ type IsMobileEmulatorEnabled = () => boolean
|
||||
const CONTROL_MODIFIERS = new Set(['control', 'ctrl'])
|
||||
const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd'])
|
||||
const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift'])
|
||||
const GUEST_WHEEL_ZOOM_DEDUPE_MS = 250
|
||||
|
||||
type GuestWheelZoomDirection = Exclude<BrowserPageZoomDirection, 'reset'>
|
||||
|
||||
const recentGuestWheelZoomByGuest = new WeakMap<
|
||||
Electron.WebContents,
|
||||
{ direction: GuestWheelZoomDirection; at: number }
|
||||
>()
|
||||
|
||||
function markGuestWheelZoom(guest: Electron.WebContents, direction: GuestWheelZoomDirection): void {
|
||||
recentGuestWheelZoomByGuest.set(guest, { direction, at: Date.now() })
|
||||
}
|
||||
|
||||
function consumeRecentGuestWheelZoom(
|
||||
guest: Electron.WebContents,
|
||||
direction: GuestWheelZoomDirection
|
||||
): boolean {
|
||||
const recent = recentGuestWheelZoomByGuest.get(guest)
|
||||
if (!recent) {
|
||||
return false
|
||||
}
|
||||
const elapsed = Date.now() - recent.at
|
||||
if (elapsed < 0 || elapsed > GUEST_WHEEL_ZOOM_DEDUPE_MS) {
|
||||
recentGuestWheelZoomByGuest.delete(guest)
|
||||
return false
|
||||
}
|
||||
if (recent.direction !== direction) {
|
||||
return false
|
||||
}
|
||||
recentGuestWheelZoomByGuest.delete(guest)
|
||||
return true
|
||||
}
|
||||
|
||||
function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet<string>): boolean {
|
||||
return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false
|
||||
@@ -37,7 +70,7 @@ function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet<str
|
||||
export function resolveGuestMouseWheelZoomDirection(
|
||||
mouse: Electron.MouseInputEvent,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): BrowserPageZoomDirection | null {
|
||||
): GuestWheelZoomDirection | null {
|
||||
if (mouse.type !== 'mouseWheel') {
|
||||
return null
|
||||
}
|
||||
@@ -277,6 +310,15 @@ export function setupGuestShortcutForwarding(args: {
|
||||
const resetDoubleTapDetector = (): void => doubleTapDetector.reset()
|
||||
type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean }
|
||||
|
||||
const forwardBrowserPageZoom = (
|
||||
event: Electron.Event,
|
||||
direction: BrowserPageZoomDirection
|
||||
): void => {
|
||||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:zoomBrowserPage', direction)
|
||||
}
|
||||
|
||||
const forwardShortcutInput = (
|
||||
event: Electron.Event,
|
||||
input: GuestShortcutInput,
|
||||
@@ -284,11 +326,9 @@ export function setupGuestShortcutForwarding(args: {
|
||||
): boolean => {
|
||||
const keybindings = getKeybindings?.()
|
||||
if (action?.type === 'zoom') {
|
||||
// Why: keyboard zoom is Orca chrome zoom. Focused guests bypass the
|
||||
// main-window shortcut path, so forward to the shared renderer zoom router.
|
||||
event.preventDefault()
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('terminal:zoom', action.direction)
|
||||
// Why: focused browser guests own page zoom, but their key events never
|
||||
// reach the renderer-owned webview ref that can apply Orca's page zoom.
|
||||
forwardBrowserPageZoom(event, action.direction)
|
||||
return true
|
||||
}
|
||||
if (input.isAutoRepeat) {
|
||||
@@ -521,11 +561,32 @@ export function setupGuestShortcutForwarding(args: {
|
||||
forwardShortcutInput(event, input, action)
|
||||
}
|
||||
|
||||
const zoomCommandHandler = (
|
||||
event: Electron.Event,
|
||||
zoomDirection: 'in' | 'out' | 'reset'
|
||||
): void => {
|
||||
if (zoomDirection !== 'in' && zoomDirection !== 'out') {
|
||||
return
|
||||
}
|
||||
// Why: some keyboard layouts/platforms turn Ctrl/Cmd +/- into Electron's
|
||||
// native zoom command before `before-input-event` reaches the guest.
|
||||
if (consumeRecentGuestWheelZoom(guest, zoomDirection)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!nativeZoomCommandMatchesKeybindings(zoomDirection, process.platform, getKeybindings?.())) {
|
||||
return
|
||||
}
|
||||
forwardBrowserPageZoom(event, zoomDirection)
|
||||
}
|
||||
|
||||
guest.on('before-input-event', handler)
|
||||
guest.on('zoom-changed', zoomCommandHandler)
|
||||
guest.on('blur', resetDoubleTapDetector)
|
||||
return () => {
|
||||
try {
|
||||
guest.off('before-input-event', handler)
|
||||
guest.off('zoom-changed', zoomCommandHandler)
|
||||
guest.off('blur', resetDoubleTapDetector)
|
||||
} catch {
|
||||
// Why: best-effort — guest may already be destroyed during teardown.
|
||||
@@ -547,6 +608,7 @@ export function setupGuestMouseWheelZoomForwarding(args: {
|
||||
// Why: wheel input over a focused webview does not reach renderer DOM
|
||||
// handlers, so consume it here and forward to the existing page-zoom path.
|
||||
event.preventDefault()
|
||||
markGuestWheelZoom(guest, direction)
|
||||
resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction)
|
||||
}
|
||||
|
||||
|
||||
@@ -1324,6 +1324,86 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds the WSL runtime config with rewritten paths and no system hook trust', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const systemCodexHomePath = join(wslHome, '.codex')
|
||||
mkdirSync(systemCodexHomePath, { recursive: true })
|
||||
writeFileSync(
|
||||
join(systemCodexHomePath, 'config.toml'),
|
||||
[
|
||||
'model_instructions_file = "instructions.md"',
|
||||
'',
|
||||
'[hooks.state."system-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'',
|
||||
'[projects."/home/alice/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
const store = createStore(createSettings())
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
)
|
||||
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain('[projects."/home/alice/repo"]')
|
||||
expect(runtimeConfig).not.toContain('[hooks.state.')
|
||||
|
||||
// Why: WSL runtime configs are seeded once; Codex writes trust into them
|
||||
// afterwards, so a relaunch must not clobber the seeded file.
|
||||
writeFileSync(runtimeConfigPath, `${runtimeConfig}\n[projects."/tmp/x"]\n`, 'utf-8')
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(readFileSync(runtimeConfigPath, 'utf-8')).toContain('[projects."/tmp/x"]')
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('anchors WSL seed rewrites to the Linux-side home parsed from the UNC source', async () => {
|
||||
const { prepareWslRuntimeSeedConfig } = await import('./runtime-home-service')
|
||||
|
||||
// Why: real UNC sources cannot back live fs operations in tests, so pin
|
||||
// the UNC -> Linux-side anchor translation on the extracted seed function.
|
||||
expect(
|
||||
prepareWslRuntimeSeedConfig(
|
||||
'model_instructions_file = "instructions.md"\n',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
)
|
||||
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
expect(
|
||||
prepareWslRuntimeSeedConfig(
|
||||
'model_instructions_file = "instructions.md"\n',
|
||||
'\\\\wsl$\\Ubuntu\\home\\alice\\.codex'
|
||||
)
|
||||
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
})
|
||||
|
||||
it('switches WSL accounts by rewriting one stable WSL runtime home', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
syncSystemCodexResourcesIntoManagedHome
|
||||
} from '../codex/codex-home-paths'
|
||||
import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from '../codex/codex-config-mirror'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
getWslSelectionKey,
|
||||
@@ -705,7 +708,10 @@ export class CodexRuntimeHomeService {
|
||||
for (const homePath of candidateHomes) {
|
||||
const configPath = join(homePath, 'config.toml')
|
||||
if (existsSync(configPath)) {
|
||||
copyFileSync(configPath, runtimeConfigPath)
|
||||
writeFileAtomically(
|
||||
runtimeConfigPath,
|
||||
prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1562,3 +1568,16 @@ export class CodexRuntimeHomeService {
|
||||
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the seed config is read over UNC but consumed by Codex inside WSL, so
|
||||
// relative path-valued settings must anchor to the Linux-side source home; a
|
||||
// verbatim copy breaks Codex config load (os error 2).
|
||||
export function prepareWslRuntimeSeedConfig(
|
||||
configContents: string,
|
||||
sourceHomePath: string
|
||||
): string {
|
||||
return prepareSystemConfigForFreshRuntimeMirror(
|
||||
configContents,
|
||||
parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ vi.mock('node:os', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from './codex-config-mirror'
|
||||
|
||||
let fakeHomeDir: string
|
||||
let userDataDir: string
|
||||
@@ -397,3 +400,32 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(existsSync(getRuntimeConfigPath())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSystemConfigForFreshRuntimeMirror', () => {
|
||||
it('rewrites relative paths against a Linux-side home and strips hook trust', () => {
|
||||
const prepared = prepareSystemConfigForFreshRuntimeMirror(
|
||||
[
|
||||
'model_instructions_file = "instructions.md"',
|
||||
'',
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
'[hooks.state."system-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'',
|
||||
'[projects."/home/alice/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'/home/alice/.codex'
|
||||
)
|
||||
|
||||
// Why: WSL configs are consumed inside the distro, so rewrites must use
|
||||
// posix join semantics regardless of the host platform.
|
||||
expect(prepared).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
expect(prepared).toContain('hooks = true')
|
||||
expect(prepared).not.toContain('codex_hooks')
|
||||
expect(prepared).toContain('[projects."/home/alice/repo"]')
|
||||
expect(prepared).not.toContain('[hooks.state."system-hooks:stop:0:0"]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,17 +35,19 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
return
|
||||
}
|
||||
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(
|
||||
systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '',
|
||||
dirname(systemConfigPath)
|
||||
)
|
||||
const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
|
||||
if (!runtimeConfigExists) {
|
||||
// Why: trust blocks reference a hooks.json path, so system-home hook trust
|
||||
// entries are not valid in Orca's runtime CODEX_HOME until install remaps them.
|
||||
writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig))
|
||||
writeFileAtomically(
|
||||
runtimeConfigPath,
|
||||
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(
|
||||
rawSystemConfig,
|
||||
dirname(systemConfigPath)
|
||||
)
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
@@ -60,6 +62,17 @@ function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: st
|
||||
)
|
||||
}
|
||||
|
||||
// Why: trust blocks reference a hooks.json path, so system-home hook trust
|
||||
// entries are not valid in a fresh runtime CODEX_HOME until install remaps
|
||||
// them. Also seeds WSL runtime homes, where systemConfigDir must be the
|
||||
// Linux-side ~/.codex the config resolves against inside the distro.
|
||||
export function prepareSystemConfigForFreshRuntimeMirror(
|
||||
config: string,
|
||||
systemConfigDir: string
|
||||
): string {
|
||||
return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir))
|
||||
}
|
||||
|
||||
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
|
||||
if (!config.includes('codex_hooks')) {
|
||||
return config
|
||||
@@ -71,7 +84,9 @@ function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
|
||||
|
||||
for (let index = 0; index <= lines.length; index += 1) {
|
||||
const line = lines[index]
|
||||
const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?$/.test(line)
|
||||
// Why: CRLF configs keep a trailing \r after the split, so header anchors
|
||||
// must tolerate it or Windows-shaped configs skip normalization entirely.
|
||||
const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?\r?$/.test(line)
|
||||
if (!isHeader) {
|
||||
continue
|
||||
}
|
||||
@@ -80,7 +95,7 @@ function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
|
||||
featureSections.push({ start: featureStart, end: index })
|
||||
featureStart = null
|
||||
}
|
||||
if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?$/.test(line)) {
|
||||
if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?\r?$/.test(line)) {
|
||||
featureStart = index
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2028,15 +2028,14 @@ describe('createPtySubprocess', () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_TERMINAL_HANDLE: 'term_wsl',
|
||||
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
|
||||
})
|
||||
})
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[1]).toEqual(expect.any(Array))
|
||||
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
|
||||
// Why: the daemon inherits optional agent-hook env in development. This
|
||||
// test owns only the terminal handle and Powerlevel10k WSLENV contract.
|
||||
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
|
||||
expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Store } from '../persistence'
|
||||
import { findGhosttyConfigPaths } from './discovery'
|
||||
import { parseGhosttyConfig } from './parser'
|
||||
import { mapGhosttyToOrca } from './mapper'
|
||||
import { resolveGhosttyThemeColors } from './theme-resolution'
|
||||
|
||||
// Why: defensive upper bound on the Ghostty config size we're willing to read
|
||||
// into memory on the main process. Real configs are a few KB; anything past
|
||||
@@ -61,6 +62,44 @@ function mergeParsedConfig(
|
||||
}
|
||||
}
|
||||
|
||||
function usesConditionalThemePair(themeName: string): boolean {
|
||||
return themeName
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.some((part) => part.startsWith('light:') || part.startsWith('dark:'))
|
||||
}
|
||||
|
||||
// Why: `theme = <name>` is how most Ghostty configs pick their colors, so
|
||||
// dropping it silently loses the entire palette. Resolve the referenced theme
|
||||
// file and merge its colors as defaults: explicit config keys win, and config
|
||||
// palette entries are appended after the theme's so per-index overrides apply.
|
||||
async function applyThemeReference(parsed: Record<string, string | string[]>): Promise<string[]> {
|
||||
const rawTheme = parsed['theme']
|
||||
if (rawTheme === undefined) {
|
||||
return []
|
||||
}
|
||||
delete parsed['theme']
|
||||
|
||||
const themeName = (Array.isArray(rawTheme) ? (rawTheme.at(-1) ?? '') : rawTheme).trim()
|
||||
if (usesConditionalThemePair(themeName)) {
|
||||
return ['theme (light:/dark: pairs not supported)']
|
||||
}
|
||||
|
||||
const themeColors = await resolveGhosttyThemeColors(themeName)
|
||||
if (themeColors === null) {
|
||||
return ['theme (theme file not found)']
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(themeColors)) {
|
||||
if (key === 'palette') {
|
||||
parsed[key] = [...asArray(value), ...asArray(parsed[key])]
|
||||
} else if (parsed[key] === undefined) {
|
||||
parsed[key] = value
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function previewGhosttyImport(store: Store): Promise<GhosttyImportPreview> {
|
||||
const configPaths = await findGhosttyConfigPaths()
|
||||
if (configPaths.length === 0) {
|
||||
@@ -93,7 +132,13 @@ export async function previewGhosttyImport(store: Store): Promise<GhosttyImportP
|
||||
mergeParsedConfig(parsed, parseGhosttyConfig(content))
|
||||
}
|
||||
|
||||
const { diff: rawDiff, unsupportedKeys } = mapGhosttyToOrca(parsed, platform() === 'darwin')
|
||||
const themeUnsupportedKeys = await applyThemeReference(parsed)
|
||||
|
||||
const { diff: rawDiff, unsupportedKeys: mappedUnsupportedKeys } = mapGhosttyToOrca(
|
||||
parsed,
|
||||
platform() === 'darwin'
|
||||
)
|
||||
const unsupportedKeys = [...mappedUnsupportedKeys, ...themeUnsupportedKeys]
|
||||
|
||||
const currentSettings = store.getSettings()
|
||||
const actualDiff: Partial<typeof rawDiff> = {}
|
||||
|
||||
@@ -77,6 +77,110 @@ describe('mapGhosttyToOrca — window-padding', () => {
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['window-padding-x'])
|
||||
})
|
||||
|
||||
it('averages a dual-value window-padding-y', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-y': '10,8' })
|
||||
expect(result.diff).toEqual({ terminalPaddingY: 9 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('averages a dual-value window-padding-x with surrounding whitespace', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-x': '16, 12' })
|
||||
expect(result.diff).toEqual({ terminalPaddingX: 14 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves odd-sum dual-value padding as a fractional average', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' })
|
||||
expect(result.diff).toEqual({ terminalPaddingX: 1.5 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a dual-value padding with an invalid half', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-y': '10,wide' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['window-padding-y'])
|
||||
})
|
||||
|
||||
it('rejects a dual-value padding with an out-of-range half', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-y': '10,600' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['window-padding-y'])
|
||||
})
|
||||
|
||||
it('rejects paddings with more than two values', () => {
|
||||
const result = mapGhosttyToOrca({ 'window-padding-y': '10,8,6' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['window-padding-y'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapGhosttyToOrca — adjust-cell-height', () => {
|
||||
it('maps a percentage to terminalLineHeight', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '35%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 1.35 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('rounds the mapped line height to two decimals', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '33%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 1.33 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('rounds half-boundary decimal percentages to the nearest hundredth', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '0.5%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 1.01 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('rounds higher half-boundary decimal percentages to the nearest hundredth', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '1.5%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 1.02 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('rounds high half-boundary decimal percentages without floating-point drift', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '101.5%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 2.02 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts the inclusive 1x line-height floor', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '0%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 1 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts the inclusive 3x line-height ceiling', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '200%' })
|
||||
expect(result.diff).toEqual({ terminalLineHeight: 3 })
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a pixel value (not convertible to a line-height ratio)', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '2' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
|
||||
})
|
||||
|
||||
it('rejects a negative percentage (below the 1x line-height floor)', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '-10%' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
|
||||
})
|
||||
|
||||
it('rejects a percentage above the 3x line-height ceiling', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '250%' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
|
||||
})
|
||||
|
||||
it('rejects values that only fall inside the ceiling after rounding', () => {
|
||||
const result = mapGhosttyToOrca({ 'adjust-cell-height': '200.4%' })
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['adjust-cell-height'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapGhosttyToOrca — cursor-text', () => {
|
||||
|
||||
+22
-16
@@ -4,6 +4,7 @@ import type {
|
||||
GhosttyImportPreview
|
||||
} from '../../shared/types'
|
||||
import { HEX_COLOR_RE } from '../../shared/color-validation'
|
||||
import { parsePaddingValue, parseStrictInt } from './numeric-config-values'
|
||||
|
||||
const PALETTE_INDEX_MAP: Record<number, keyof TerminalColorOverrides> = {
|
||||
0: 'black',
|
||||
@@ -34,18 +35,6 @@ type FieldResult =
|
||||
|
||||
const normalizeHex = (v: string): string => (v.startsWith('#') ? v : `#${v}`)
|
||||
|
||||
// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
|
||||
// config with `window-padding-x = 1e9` would sail through the mapper and land
|
||||
// an absurd value in the store. Restrict to plain decimal integers.
|
||||
const STRICT_INT_RE = /^-?\d+$/
|
||||
const parseStrictInt = (v: string): number | null => {
|
||||
if (!STRICT_INT_RE.test(v)) {
|
||||
return null
|
||||
}
|
||||
const num = Number(v)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
type FieldParser = (value: string, rawValue: string | string[]) => FieldResult
|
||||
|
||||
export function mapGhosttyToOrca(
|
||||
@@ -171,21 +160,38 @@ export function mapGhosttyToOrca(
|
||||
},
|
||||
|
||||
'window-padding-x': (v) => {
|
||||
const num = parseStrictInt(v)
|
||||
if (num === null || num < 0 || num > 512) {
|
||||
const num = parsePaddingValue(v)
|
||||
if (num === null) {
|
||||
return null
|
||||
}
|
||||
return { key: 'terminalPaddingX', value: num }
|
||||
},
|
||||
|
||||
'window-padding-y': (v) => {
|
||||
const num = parseStrictInt(v)
|
||||
if (num === null || num < 0 || num > 512) {
|
||||
const num = parsePaddingValue(v)
|
||||
if (num === null) {
|
||||
return null
|
||||
}
|
||||
return { key: 'terminalPaddingY', value: num }
|
||||
},
|
||||
|
||||
// Why: only percentages translate to xterm's line-height ratio; Ghostty's
|
||||
// pixel form depends on the rendered cell height, which we can't know here.
|
||||
// The settings UI clamps terminalLineHeight to [1, 3], so reject outside it.
|
||||
'adjust-cell-height': (v) => {
|
||||
const match = /^\+?(\d+(?:\.\d+)?)%$/.exec(v.trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const percent = Number(match[1])
|
||||
const rawLineHeight = 1 + percent / 100
|
||||
if (rawLineHeight > 3) {
|
||||
return null
|
||||
}
|
||||
const lineHeight = Math.round(100 + percent) / 100
|
||||
return { key: 'terminalLineHeight', value: lineHeight }
|
||||
},
|
||||
|
||||
'cursor-text': (v) => {
|
||||
if (!HEX_COLOR_RE.test(v)) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty
|
||||
// config with `window-padding-x = 1e9` would sail through the mapper and land
|
||||
// an absurd value in the store. Restrict to plain decimal integers.
|
||||
const STRICT_INT_RE = /^-?\d+$/
|
||||
export const parseStrictInt = (v: string): number | null => {
|
||||
if (!STRICT_INT_RE.test(v)) {
|
||||
return null
|
||||
}
|
||||
const num = Number(v)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings,
|
||||
// but Orca stores a single value per axis — average the pair so the total
|
||||
// padding along the axis stays the same.
|
||||
export const parsePaddingValue = (v: string): number | null => {
|
||||
const parts = v.split(',')
|
||||
if (parts.length > 2) {
|
||||
return null
|
||||
}
|
||||
const nums: number[] = []
|
||||
for (const part of parts) {
|
||||
const num = parseStrictInt(part.trim())
|
||||
if (num === null || num < 0 || num > 512) {
|
||||
return null
|
||||
}
|
||||
nums.push(num)
|
||||
}
|
||||
return nums.reduce((sum, num) => sum + num, 0) / nums.length
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Store } from '../persistence'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { statMock, readFileMock } = vi.hoisted(() => ({
|
||||
statMock: vi.fn(),
|
||||
readFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: statMock,
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('os', () => ({
|
||||
platform: vi.fn(() => 'darwin'),
|
||||
homedir: vi.fn(() => '/Users/alice')
|
||||
}))
|
||||
|
||||
import { previewGhosttyImport } from './index'
|
||||
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
|
||||
const originalGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.GHOSTTY_RESOURCES_DIR
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (originalXdgConfigHome !== undefined) {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
|
||||
} else {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
}
|
||||
if (originalGhosttyResourcesDir !== undefined) {
|
||||
process.env.GHOSTTY_RESOURCES_DIR = originalGhosttyResourcesDir
|
||||
} else {
|
||||
delete process.env.GHOSTTY_RESOURCES_DIR
|
||||
}
|
||||
})
|
||||
|
||||
function createStore(settings: Record<string, unknown> = {}): Store {
|
||||
return {
|
||||
getSettings: () => settings as GlobalSettings
|
||||
} as Store
|
||||
}
|
||||
|
||||
describe('previewGhosttyImport theme references', () => {
|
||||
it('resolves a theme reference into color overrides', async () => {
|
||||
const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
|
||||
const themePath = '/Users/alice/.config/ghostty/themes/Tomorrow Night Bright'
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === configPath || p === themePath) {
|
||||
return { isFile: () => true, size: 128 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockImplementation(async (p: string) => {
|
||||
if (p === themePath) {
|
||||
return 'palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n'
|
||||
}
|
||||
return 'theme = Tomorrow Night Bright\nfont-size = 14\n'
|
||||
})
|
||||
|
||||
const result = await previewGhosttyImport(createStore())
|
||||
|
||||
expect(result.diff).toEqual({
|
||||
terminalFontSize: 14,
|
||||
terminalColorOverrides: {
|
||||
red: '#d54e53',
|
||||
background: '#000000',
|
||||
foreground: '#eaeaea'
|
||||
}
|
||||
})
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('lets explicit config colors override theme colors', async () => {
|
||||
const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
|
||||
const themePath = '/Users/alice/.config/ghostty/themes/night'
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === configPath || p === themePath) {
|
||||
return { isFile: () => true, size: 128 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockImplementation(async (p: string) => {
|
||||
if (p === themePath) {
|
||||
return 'palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n'
|
||||
}
|
||||
return 'theme = night\nbackground = #101010\npalette = 1=#ff0000\n'
|
||||
})
|
||||
|
||||
const result = await previewGhosttyImport(createStore())
|
||||
|
||||
expect(result.diff).toEqual({
|
||||
terminalColorOverrides: {
|
||||
// Why: config's palette index 1 and background win; theme keeps index 2.
|
||||
red: '#ff0000',
|
||||
green: '#b9ca4a',
|
||||
background: '#101010'
|
||||
}
|
||||
})
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves an absolute theme path without probing theme search dirs', async () => {
|
||||
const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
|
||||
const themePath = '/Users/alice/themes/work'
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === configPath || p === themePath) {
|
||||
return { isFile: () => true, size: 128 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockImplementation(async (p: string) => {
|
||||
if (p === themePath) {
|
||||
return 'background = #202020\nforeground = #f0f0f0\n'
|
||||
}
|
||||
return `theme = ${themePath}\n`
|
||||
})
|
||||
|
||||
const result = await previewGhosttyImport(createStore())
|
||||
|
||||
expect(result.diff).toEqual({
|
||||
terminalColorOverrides: {
|
||||
background: '#202020',
|
||||
foreground: '#f0f0f0'
|
||||
}
|
||||
})
|
||||
expect(statMock).toHaveBeenCalledWith(themePath)
|
||||
expect(statMock).not.toHaveBeenCalledWith(
|
||||
'/Users/alice/.config/ghostty/themes/Users/alice/themes/work'
|
||||
)
|
||||
expect(result.unsupportedKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('marks an unresolvable theme as unsupported', async () => {
|
||||
const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === configPath) {
|
||||
return { isFile: () => true, size: 128 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockResolvedValue('theme = Missing Theme\n')
|
||||
|
||||
const result = await previewGhosttyImport(createStore())
|
||||
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['theme (theme file not found)'])
|
||||
})
|
||||
|
||||
it('marks light:/dark: theme pairs as unsupported', async () => {
|
||||
const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config'
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === configPath) {
|
||||
return { isFile: () => true, size: 128 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockResolvedValue('theme = light:Tomorrow,dark:Tomorrow Night\n')
|
||||
|
||||
const result = await previewGhosttyImport(createStore())
|
||||
|
||||
expect(result.diff).toEqual({})
|
||||
expect(result.unsupportedKeys).toEqual(['theme (light:/dark: pairs not supported)'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { statMock, readFileMock } = vi.hoisted(() => ({
|
||||
statMock: vi.fn(),
|
||||
readFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: statMock,
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('os', () => ({
|
||||
platform: vi.fn(() => 'darwin'),
|
||||
homedir: vi.fn(() => '/Users/alice')
|
||||
}))
|
||||
|
||||
import { getGhosttyThemeSearchDirs, resolveGhosttyThemeColors } from './theme-resolution'
|
||||
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
|
||||
const originalGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.GHOSTTY_RESOURCES_DIR
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (originalXdgConfigHome !== undefined) {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
|
||||
} else {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
}
|
||||
if (originalGhosttyResourcesDir !== undefined) {
|
||||
process.env.GHOSTTY_RESOURCES_DIR = originalGhosttyResourcesDir
|
||||
} else {
|
||||
delete process.env.GHOSTTY_RESOURCES_DIR
|
||||
}
|
||||
})
|
||||
|
||||
const THEME_FILE = `
|
||||
palette = 0=#000000
|
||||
palette = 1=#d54e53
|
||||
background = #000000
|
||||
foreground = #eaeaea
|
||||
cursor-color = #eaeaea
|
||||
selection-background = #424242
|
||||
selection-foreground = #eaeaea
|
||||
`
|
||||
|
||||
describe('getGhosttyThemeSearchDirs', () => {
|
||||
it('probes XDG, then the Ghostty.app resources dir', () => {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
expect(getGhosttyThemeSearchDirs()).toEqual([
|
||||
'/Users/alice/.config/ghostty/themes',
|
||||
'/Applications/Ghostty.app/Contents/Resources/ghostty/themes'
|
||||
])
|
||||
})
|
||||
|
||||
it('honors XDG_CONFIG_HOME for the custom themes dir', () => {
|
||||
process.env.XDG_CONFIG_HOME = '/xdg'
|
||||
expect(getGhosttyThemeSearchDirs()[0]).toBe('/xdg/ghostty/themes')
|
||||
})
|
||||
|
||||
it('honors GHOSTTY_RESOURCES_DIR for bundled themes', () => {
|
||||
process.env.GHOSTTY_RESOURCES_DIR = '/opt/ghostty'
|
||||
expect(getGhosttyThemeSearchDirs()).toEqual([
|
||||
'/Users/alice/.config/ghostty/themes',
|
||||
'/opt/ghostty/themes'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveGhosttyThemeColors', () => {
|
||||
it('reads the first matching theme file and returns only color keys', async () => {
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === '/Users/alice/.config/ghostty/themes/Tomorrow Night Bright') {
|
||||
return { isFile: () => true, size: THEME_FILE.length }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockResolvedValue(THEME_FILE)
|
||||
|
||||
const result = await resolveGhosttyThemeColors('Tomorrow Night Bright')
|
||||
expect(result).toEqual({
|
||||
palette: ['0=#000000', '1=#d54e53'],
|
||||
background: '#000000',
|
||||
foreground: '#eaeaea',
|
||||
'cursor-color': '#eaeaea',
|
||||
'selection-background': '#424242',
|
||||
'selection-foreground': '#eaeaea'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls through to later dirs when earlier ones miss', async () => {
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === '/Applications/Ghostty.app/Contents/Resources/ghostty/themes/Tomorrow') {
|
||||
return { isFile: () => true, size: THEME_FILE.length }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockResolvedValue('background = #1d1f21')
|
||||
|
||||
const result = await resolveGhosttyThemeColors('Tomorrow')
|
||||
expect(result).toEqual({ background: '#1d1f21' })
|
||||
})
|
||||
|
||||
it('resolves absolute theme paths without searching named theme dirs', async () => {
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === '/Users/alice/themes/work') {
|
||||
return { isFile: () => true, size: 64 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockResolvedValue('background = #1d1f21')
|
||||
|
||||
const result = await resolveGhosttyThemeColors('/Users/alice/themes/work')
|
||||
expect(result).toEqual({ background: '#1d1f21' })
|
||||
expect(statMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops non-color keys a theme file might carry', async () => {
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === '/Users/alice/.config/ghostty/themes/custom') {
|
||||
return { isFile: () => true, size: 64 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
readFileMock.mockResolvedValue('background = #101010\nfont-size = 20')
|
||||
|
||||
const result = await resolveGhosttyThemeColors('custom')
|
||||
expect(result).toEqual({ background: '#101010' })
|
||||
})
|
||||
|
||||
it('returns null when the theme is not found anywhere', async () => {
|
||||
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
expect(await resolveGhosttyThemeColors('Missing Theme')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects names containing path separators or traversal', async () => {
|
||||
expect(await resolveGhosttyThemeColors('../etc/passwd')).toBeNull()
|
||||
expect(await resolveGhosttyThemeColors('a/b')).toBeNull()
|
||||
expect(await resolveGhosttyThemeColors('a\\b')).toBeNull()
|
||||
expect(await resolveGhosttyThemeColors('..')).toBeNull()
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized theme files', async () => {
|
||||
statMock.mockImplementation(async () => ({ isFile: () => true, size: 10_000_000 }))
|
||||
expect(await resolveGhosttyThemeColors('huge')).toBeNull()
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
expect(statMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fall through when a shadowing theme path is invalid', async () => {
|
||||
statMock.mockImplementation(async (p: string) => {
|
||||
if (p === '/Users/alice/.config/ghostty/themes/night') {
|
||||
return { isFile: () => false, size: 128 }
|
||||
}
|
||||
if (p === '/Applications/Ghostty.app/Contents/Resources/ghostty/themes/night') {
|
||||
return { isFile: () => true, size: 128 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
expect(await resolveGhosttyThemeColors('night')).toBeNull()
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
expect(statMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { homedir, platform } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { parseGhosttyConfig } from './parser'
|
||||
|
||||
// Why: theme files ship a few dozen short lines; anything larger is not a
|
||||
// Ghostty theme and should not be read into the main process.
|
||||
const MAX_THEME_BYTES = 262_144
|
||||
type ThemeColors = Record<string, string | string[]>
|
||||
type ThemeReadResult =
|
||||
| { status: 'found'; colors: ThemeColors }
|
||||
| { status: 'missing' }
|
||||
| { status: 'invalid' }
|
||||
|
||||
// Why: only color-bearing keys may flow from a theme into the import — a theme
|
||||
// file must not be able to smuggle font/window settings past the user's config.
|
||||
const THEME_COLOR_KEYS = new Set([
|
||||
'palette',
|
||||
'background',
|
||||
'foreground',
|
||||
'cursor-color',
|
||||
'cursor-text',
|
||||
'selection-background',
|
||||
'selection-foreground',
|
||||
'bold-color',
|
||||
'split-divider-color'
|
||||
])
|
||||
|
||||
function xdgThemeDirs(home: string): string[] {
|
||||
if (process.env.XDG_CONFIG_HOME) {
|
||||
return [path.posix.join(process.env.XDG_CONFIG_HOME, 'ghostty', 'themes')]
|
||||
}
|
||||
return [path.posix.join(home, '.config', 'ghostty', 'themes')]
|
||||
}
|
||||
|
||||
function resourceThemeDirs(plat: 'darwin' | 'linux'): string[] {
|
||||
if (process.env.GHOSTTY_RESOURCES_DIR) {
|
||||
return [path.posix.join(process.env.GHOSTTY_RESOURCES_DIR, 'themes')]
|
||||
}
|
||||
|
||||
if (plat === 'darwin') {
|
||||
return ['/Applications/Ghostty.app/Contents/Resources/ghostty/themes']
|
||||
}
|
||||
return ['/usr/share/ghostty/themes', '/usr/local/share/ghostty/themes']
|
||||
}
|
||||
|
||||
// Why: mirrors Ghostty's lookup — user themes shadow the bundled themes shipped
|
||||
// inside the Ghostty resources directory.
|
||||
export function getGhosttyThemeSearchDirs(): string[] {
|
||||
const home = homedir()
|
||||
const plat = platform()
|
||||
if (plat !== 'darwin' && plat !== 'linux') {
|
||||
// Why: Ghostty has no Windows build, so there are no named theme dirs to probe.
|
||||
return []
|
||||
}
|
||||
return [...xdgThemeDirs(home), ...resourceThemeDirs(plat)]
|
||||
}
|
||||
|
||||
function isMissingThemeError(err: unknown): boolean {
|
||||
if (typeof err !== 'object' || err === null || !('code' in err)) {
|
||||
return false
|
||||
}
|
||||
const code = (err as { code?: unknown }).code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function isRelativeThemeName(name: string): boolean {
|
||||
return !name.includes('/') && !name.includes('\\') && name !== '..' && name !== '.'
|
||||
}
|
||||
|
||||
function themeColorsFromContent(content: string): ThemeColors {
|
||||
const parsed = parseGhosttyConfig(content)
|
||||
const colors: ThemeColors = {}
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (THEME_COLOR_KEYS.has(key)) {
|
||||
colors[key] = value
|
||||
}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
|
||||
async function readThemeColors(themePath: string): Promise<ThemeReadResult> {
|
||||
let content: string
|
||||
try {
|
||||
const info = await stat(themePath)
|
||||
if (!info.isFile() || info.size > MAX_THEME_BYTES) {
|
||||
return { status: 'invalid' }
|
||||
}
|
||||
content = await readFile(themePath, 'utf-8')
|
||||
} catch (err) {
|
||||
return { status: isMissingThemeError(err) ? 'missing' : 'invalid' }
|
||||
}
|
||||
|
||||
return { status: 'found', colors: themeColorsFromContent(content) }
|
||||
}
|
||||
|
||||
export async function resolveGhosttyThemeColors(name: string): Promise<ThemeColors | null> {
|
||||
if (path.isAbsolute(name)) {
|
||||
const result = await readThemeColors(name)
|
||||
return result.status === 'found' ? result.colors : null
|
||||
}
|
||||
|
||||
// Why: relative theme names become filenames below; only absolute paths may
|
||||
// contain separators, matching Ghostty's traversal guard.
|
||||
if (!isRelativeThemeName(name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const dir of getGhosttyThemeSearchDirs()) {
|
||||
const themePath = path.posix.join(dir, name)
|
||||
const result = await readThemeColors(themePath)
|
||||
if (result.status === 'found') {
|
||||
return result.colors
|
||||
}
|
||||
if (result.status === 'invalid') {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -3,12 +3,30 @@
|
||||
// and stderr extraction from execFile rejections (err.message is unreliable).
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendGitConfigEnv,
|
||||
extractExecError,
|
||||
isTransientGhError,
|
||||
nonInteractiveGitEnv,
|
||||
parseRetryAfterMs,
|
||||
promptGuardGitEnv,
|
||||
redirectPortedHostnameToEnv
|
||||
} from './runner'
|
||||
|
||||
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
|
||||
// back into a plain key→value map so tests can assert on it directly.
|
||||
function readGitConfigEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10)
|
||||
const config: Record<string, string> = {}
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = env[`GIT_CONFIG_KEY_${i}`]
|
||||
const value = env[`GIT_CONFIG_VALUE_${i}`]
|
||||
if (key !== undefined && value !== undefined) {
|
||||
config[key] = value
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
describe('redirectPortedHostnameToEnv', () => {
|
||||
it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => {
|
||||
const { args, options } = redirectPortedHostnameToEnv(
|
||||
@@ -147,3 +165,56 @@ describe('extractExecError', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendGitConfigEnv', () => {
|
||||
it('injects entries starting at count 0 when none exist', () => {
|
||||
const env = appendGitConfigEnv({ PATH: '/usr/bin' }, [['credential.interactive', 'false']])
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1')
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe('false')
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('composes with an existing count instead of clobbering caller config', () => {
|
||||
const env = appendGitConfigEnv(
|
||||
{ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.quotePath', GIT_CONFIG_VALUE_0: 'false' },
|
||||
[['credential.guiPrompt', 'false']]
|
||||
)
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2')
|
||||
// Existing entry preserved.
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('core.quotePath')
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe('false')
|
||||
// New entry appended at the next index.
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptGuardGitEnv credential-interactivity disable (STA-1292)', () => {
|
||||
it('disables the GCM GUI prompt without nuking the credential helper', () => {
|
||||
const env = promptGuardGitEnv({ PATH: '/usr/bin' })
|
||||
// GCM: never show the GUI, but still serve cached credentials.
|
||||
expect(env.GCM_INTERACTIVE).toBe('never')
|
||||
const config = readGitConfigEnv(env)
|
||||
expect(config['credential.interactive']).toBe('false')
|
||||
expect(config['credential.guiPrompt']).toBe('false')
|
||||
// Regression guard: we must NOT clear the helper — that would break
|
||||
// cached-credential auth for private repos.
|
||||
expect(config['credential.helper']).toBeUndefined()
|
||||
// Existing prompt guards remain intact.
|
||||
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', () => {
|
||||
it('carries the credential-interactivity disable through from promptGuardGitEnv', () => {
|
||||
const env = nonInteractiveGitEnv({ PATH: '/usr/bin' })
|
||||
expect(env.GCM_INTERACTIVE).toBe('never')
|
||||
const config = readGitConfigEnv(env)
|
||||
expect(config['credential.interactive']).toBe('false')
|
||||
expect(config['credential.guiPrompt']).toBe('false')
|
||||
expect(config['credential.helper']).toBeUndefined()
|
||||
// Its own BatchMode SSH guard is still applied and unaffected.
|
||||
expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
|
||||
})
|
||||
})
|
||||
|
||||
+41
-7
@@ -526,13 +526,47 @@ export function gitOptionalLocksDisabledEnv(
|
||||
}
|
||||
}
|
||||
|
||||
function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
|
||||
SSH_ASKPASS: env.SSH_ASKPASS ?? ''
|
||||
}
|
||||
/**
|
||||
* Append git config entries through the GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n /
|
||||
* GIT_CONFIG_VALUE_n env protocol (git >= 2.31), composing with any count
|
||||
* already present in `env` so we never clobber config a caller injected the
|
||||
* same way.
|
||||
*/
|
||||
export function appendGitConfigEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
entries: readonly (readonly [key: string, value: string])[]
|
||||
): NodeJS.ProcessEnv {
|
||||
const parsed = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10)
|
||||
const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
|
||||
const next = { ...env }
|
||||
entries.forEach(([key, value], index) => {
|
||||
next[`GIT_CONFIG_KEY_${base + index}`] = key
|
||||
next[`GIT_CONFIG_VALUE_${base + index}`] = value
|
||||
})
|
||||
next.GIT_CONFIG_COUNT = String(base + entries.length)
|
||||
return next
|
||||
}
|
||||
|
||||
export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
return appendGitConfigEnv(
|
||||
{
|
||||
...env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
|
||||
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
|
||||
// Why: Git Credential Manager ignores GIT_TERMINAL_PROMPT / GIT_ASKPASS and
|
||||
// pops a GUI on first auth — the Windows worktree-create hang (STA-1292).
|
||||
// `never` suppresses the prompt while still serving cached credentials.
|
||||
GCM_INTERACTIVE: 'never'
|
||||
},
|
||||
// Why: disable only the *interactive* credential prompt, NOT the helper
|
||||
// itself — an empty credential.helper would break cached-credential auth for
|
||||
// private repos. Harmless on macOS/Linux (no GCM) and on the SSH path.
|
||||
[
|
||||
['credential.interactive', 'false'],
|
||||
['credential.guiPrompt', 'false']
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
listWorktrees,
|
||||
moveWorktree,
|
||||
parseWorktreeList,
|
||||
removeWorktree
|
||||
removeWorktree,
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
} from './worktree'
|
||||
|
||||
describe('listWorktrees in-flight sharing', () => {
|
||||
@@ -482,7 +483,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
@@ -507,10 +508,30 @@ describe('addWorktree', () => {
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'add', '/repo-feature', 'feature/test'], { cwd: '/repo' }]
|
||||
[
|
||||
['worktree', 'add', '/repo-feature', 'feature/test'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
|
||||
// Why: without a timeout, a OneDrive cloud-placeholder checkout can stall
|
||||
// `git worktree add` for minutes. Assert the runner receives a non-zero
|
||||
// timeout so a stuck create fails fast instead of hanging forever.
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
|
||||
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
|
||||
)
|
||||
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS })
|
||||
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not write branch base config when no base branch is provided', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
|
||||
@@ -520,7 +541,7 @@ describe('addWorktree', () => {
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[
|
||||
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[['config', '--get', 'push.autoSetupRemote'], { cwd: '/repo-feature' }]
|
||||
])
|
||||
@@ -602,7 +623,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
@@ -640,7 +661,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
@@ -679,7 +700,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
@@ -719,7 +740,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
]
|
||||
])
|
||||
})
|
||||
@@ -769,7 +790,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/remotes/origin/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
],
|
||||
[
|
||||
[
|
||||
@@ -1283,7 +1304,7 @@ describe('addWorktree', () => {
|
||||
'/repo-feature',
|
||||
'refs/heads/main'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ type LocalBaseRefRefreshability =
|
||||
|
||||
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
|
||||
|
||||
// Why: bound `git worktree add` so a OneDrive cloud-placeholder base path can't
|
||||
// stall its checkout writes for minutes (STA-1292) — a stuck create then fails
|
||||
// fast instead of spinning forever. Generous enough not to kill a legit large
|
||||
// checkout (mirrors the SYNC runner's cloud-placeholder floor, #7225).
|
||||
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
|
||||
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
@@ -878,7 +884,12 @@ async function performAddWorktree(
|
||||
args.push(effectiveBase)
|
||||
}
|
||||
}
|
||||
await gitExecFileAsync(args, gitExecOptions(repoPath, options))
|
||||
await gitExecFileAsync(args, {
|
||||
...gitExecOptions(repoPath, options),
|
||||
// Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292)
|
||||
// fails fast rather than hanging worktree creation indefinitely.
|
||||
timeout: WORKTREE_ADD_TIMEOUT_MS
|
||||
})
|
||||
|
||||
if (options.checkoutExistingBranch) {
|
||||
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
|
||||
|
||||
@@ -22,12 +22,12 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
getPullRequestPushTargetMock.mockResolvedValue({
|
||||
pushTarget: {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'feat/onboarding-model-choice-782',
|
||||
branchName: 'fix-issue-6933',
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
})
|
||||
const fetchRemoteTrackingRef = vi.fn(async (_remote: string, branch: string) => {
|
||||
if (branch === 'feat/onboarding-model-choice-782') {
|
||||
if (branch === 'fix-issue-6933') {
|
||||
throw new Error('fatal: could not find remote ref')
|
||||
}
|
||||
})
|
||||
@@ -40,28 +40,25 @@ describe('resolveGitHubPrStartPoint', () => {
|
||||
|
||||
const result = await resolveGitHubPrStartPoint({
|
||||
repoPath: '/repo-root',
|
||||
prNumber: 1849,
|
||||
headRefName: 'feat/onboarding-model-choice-782',
|
||||
prNumber: 6934,
|
||||
headRefName: 'fix-issue-6933',
|
||||
baseRefName: 'main',
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
resolveRemote: async () => 'origin'
|
||||
})
|
||||
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'origin',
|
||||
'feat/onboarding-model-choice-782'
|
||||
)
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'fix-issue-6933')
|
||||
expect(fetchRemoteTrackingRef).toHaveBeenCalledWith('origin', 'main')
|
||||
expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'])
|
||||
expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/6934/head'])
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'def456',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
headSha: 'def456',
|
||||
branchNameOverride: 'feat/onboarding-model-choice-782',
|
||||
branchNameOverride: 'fix-issue-6933',
|
||||
pushTarget: {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'feat/onboarding-model-choice-782',
|
||||
branchName: 'fix-issue-6933',
|
||||
remoteUrl: 'git@github.com:contributor/orca.git'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2095,6 +2095,8 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
|
||||
automations.start()
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
|
||||
+41
-14
@@ -9,6 +9,7 @@ import {
|
||||
TERMINAL_INPUT_MAX_BYTES
|
||||
} from '../../shared/terminal-input'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||
|
||||
const isWindowsHost = process.platform === 'win32'
|
||||
const posixOnlyIt = isWindowsHost ? it.skip : it
|
||||
@@ -1307,6 +1308,7 @@ describe('registerPtyHandlers', () => {
|
||||
// from main and PR #2662 command threading for OMP target selection.
|
||||
spawnArgs?: {
|
||||
cwd?: string
|
||||
worktreeId?: string
|
||||
shellOverride?: string
|
||||
command?: string
|
||||
envToDelete?: string[]
|
||||
@@ -1500,7 +1502,10 @@ describe('registerPtyHandlers', () => {
|
||||
CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
|
||||
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
|
||||
},
|
||||
{ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' }
|
||||
{
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo',
|
||||
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\test\\repo'
|
||||
}
|
||||
)
|
||||
const { env } = spawnOptions
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
@@ -4348,7 +4353,7 @@ describe('registerPtyHandlers', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
tabId: 'tab-race',
|
||||
leafId,
|
||||
env: { ORCA_PANE_KEY: paneKey },
|
||||
@@ -4362,12 +4367,12 @@ describe('registerPtyHandlers', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
tabId: 'tab-race',
|
||||
leafId,
|
||||
env: {
|
||||
ORCA_TAB_ID: 'tab-race',
|
||||
ORCA_WORKTREE_ID: 'wt-1'
|
||||
ORCA_WORKTREE_ID: 'repo-1::/tmp'
|
||||
}
|
||||
}) as Promise<{ id: string }>
|
||||
await Promise.resolve()
|
||||
@@ -4380,7 +4385,7 @@ describe('registerPtyHandlers', () => {
|
||||
])
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
tabId: 'tab-race',
|
||||
leafId,
|
||||
ptyId: 'pty-shared',
|
||||
@@ -4462,13 +4467,13 @@ describe('registerPtyHandlers', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
tabId: 'tab-race',
|
||||
leafId,
|
||||
env: {
|
||||
ORCA_PANE_KEY: paneKey,
|
||||
ORCA_TAB_ID: 'tab-race',
|
||||
ORCA_WORKTREE_ID: 'wt-1'
|
||||
ORCA_WORKTREE_ID: 'repo-1::/tmp'
|
||||
}
|
||||
}) as Promise<{ id: string }>
|
||||
await Promise.resolve()
|
||||
@@ -4478,7 +4483,7 @@ describe('registerPtyHandlers', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
tabId: 'tab-race',
|
||||
leafId,
|
||||
env: { ORCA_PANE_KEY: paneKey },
|
||||
@@ -4494,7 +4499,7 @@ describe('registerPtyHandlers', () => {
|
||||
])
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
tabId: 'tab-race',
|
||||
leafId,
|
||||
ptyId: 'pty-renderer',
|
||||
@@ -5838,6 +5843,22 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes floating terminal cwds through to the spawned shell', async () => {
|
||||
// Why: the floating sentinel has no worktree root; its cwd is validated
|
||||
// against trusted-directory grants before it reaches pty:spawn.
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp/floating-notes',
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
|
||||
})
|
||||
|
||||
const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }]
|
||||
expect(options.cwd).toBe('/tmp/floating-notes')
|
||||
})
|
||||
|
||||
it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
@@ -5848,8 +5869,10 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
process.env.USERPROFILE = 'C:\\Users\\jinwo'
|
||||
|
||||
// Why: the startup-cwd guard normalizes separators, so the provider sees
|
||||
// the forward-slash UNC form.
|
||||
existsSyncMock.mockImplementation((targetPath: string) => {
|
||||
if (targetPath === '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') {
|
||||
if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -5862,10 +5885,11 @@ describe('registerPtyHandlers', () => {
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing'
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing',
|
||||
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\jin'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.'
|
||||
'Working directory "//wsl.localhost/Ubuntu/home/jin/missing" does not exist.'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
@@ -7352,7 +7376,8 @@ describe('registerPtyHandlers', () => {
|
||||
const result = await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp'
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'repo-1::/tmp'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ id: expect.any(String), pid: 12345 })
|
||||
@@ -7392,7 +7417,8 @@ describe('registerPtyHandlers', () => {
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp'
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'repo-1::/tmp'
|
||||
})
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
@@ -7708,6 +7734,7 @@ describe('registerPtyHandlers', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
worktreeId: 'repo-1::/tmp',
|
||||
env: { SHELL: '/opt/homebrew/bin/bash' }
|
||||
})
|
||||
|
||||
|
||||
+9
-4
@@ -78,6 +78,7 @@ import {
|
||||
parsePaneKey
|
||||
} from '../../shared/stable-pane-id'
|
||||
import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
|
||||
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
|
||||
import {
|
||||
clearMigrationUnsupportedPty,
|
||||
clearMigrationUnsupportedPtysForPaneKey
|
||||
@@ -1975,13 +1976,17 @@ export function registerPtyHandlers(
|
||||
|
||||
const resolveGuardedPtySpawnCwd = (
|
||||
worktreeId: string | undefined,
|
||||
cwd: string | undefined
|
||||
cwd: string | undefined,
|
||||
connectionId?: string | null
|
||||
): string | undefined =>
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: worktreeId,
|
||||
requestedCwd: cwd,
|
||||
resolveFolderWorkspacePath: (folderWorkspaceId) =>
|
||||
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
|
||||
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath,
|
||||
// Why: realpath only makes sense on the local filesystem; SSH worktree
|
||||
// paths live on the remote host.
|
||||
canonicalizePath: localTerminalCwdCanonicalizer(connectionId)
|
||||
})
|
||||
|
||||
// Why: the runtime controller must route through getProviderForPty() so that
|
||||
@@ -1994,7 +1999,7 @@ export function registerPtyHandlers(
|
||||
await startupPromise
|
||||
}
|
||||
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
||||
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
|
||||
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
|
||||
const provider = getProvider(args.connectionId)
|
||||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
@@ -2574,7 +2579,7 @@ export function registerPtyHandlers(
|
||||
await startupPromise
|
||||
}
|
||||
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
|
||||
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
|
||||
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
|
||||
spawnTiming.mark('preflight')
|
||||
const provider = getProvider(args.connectionId)
|
||||
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
|
||||
|
||||
@@ -118,6 +118,9 @@ import {
|
||||
|
||||
const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000
|
||||
const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512
|
||||
// Why: bound the create-path fallback `git fetch origin` so a Windows
|
||||
// credential-manager GUI hang (STA-1292) can't wedge worktree creation forever.
|
||||
const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000
|
||||
const sshWorktreeCreateFetchInflight = new Map<string, Promise<void>>()
|
||||
const sshWorktreeCreateFetchCompletedAt = new Map<string, number>()
|
||||
const sshWorktreeCreateFetchQueueTail = new Map<string, Promise<void>>()
|
||||
@@ -623,6 +626,26 @@ function hasRemoteCommitObject(
|
||||
return hasCommitObjectViaGitExec((gitArgs) => provider.exec(gitArgs, repoPath), ref)
|
||||
}
|
||||
|
||||
// Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is
|
||||
// a symbolic ref (refs/remotes/origin/main), so detect its presence directly so
|
||||
// SSH creates can fall back to an existing local base ref when the refresh
|
||||
// fetch fails. Require a resolved object id: a missing ref exits non-zero.
|
||||
async function hasRemoteTrackingRefSsh(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
ref: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await provider.exec(
|
||||
['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
||||
repoPath
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function canCheckoutExistingLocalBranchSsh(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
@@ -1571,32 +1594,14 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteTrackingBase) {
|
||||
try {
|
||||
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
} else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) {
|
||||
// Why: local or otherwise non-remote-tracking bases preserve legacy
|
||||
// best-effort fetch behavior. Verified PR/MR SHA bases already have the
|
||||
// commit object locally, so a broad remote fetch only updates unrelated refs.
|
||||
try {
|
||||
await fetchRemoteForWorktreeCreate(provider, repo, 'origin')
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
const mux = getActiveMultiplexer(repo.connectionId!)
|
||||
if (!mux) {
|
||||
throw new Error('SSH connection is not available. Please reconnect and try again.')
|
||||
}
|
||||
// Why: register before the local-base advisory probe as well as addWorktree.
|
||||
// Fresh/older relays may gate generic git.exec calls on registered roots; if
|
||||
// the probe runs first it degrades to "no suggestion" even though create works.
|
||||
// Why: register before any generic git.exec probe (base-ref existence, the
|
||||
// local-base advisory) as well as addWorktree. Fresh/older relays may gate
|
||||
// generic git.exec on registered roots; probing first would falsely report a
|
||||
// ref missing (and defeat the offline fallback below) even though create works.
|
||||
try {
|
||||
await Promise.all([
|
||||
mux.request('session.registerRoot', { rootPath: repo.path }),
|
||||
@@ -1611,6 +1616,34 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteTrackingBase) {
|
||||
try {
|
||||
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase)
|
||||
} catch {
|
||||
// Why: a failed refresh must not block creation when a usable local base
|
||||
// ref already exists — `git worktree add` can still create from that
|
||||
// (possibly stale but valid) ref, so a transient offline/auth failure does
|
||||
// not make the workspace uncreatable. Probe AFTER registerRoot so relays
|
||||
// that gate generic git.exec accept it; only hard-fail when there is no
|
||||
// local ref to fall back on. Drift is reflected by the compare-to-base
|
||||
// view once the remote is reachable again.
|
||||
if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) {
|
||||
// Why: local or otherwise non-remote-tracking bases preserve legacy
|
||||
// best-effort fetch behavior. Verified PR/MR SHA bases already have the
|
||||
// commit object locally, so a broad remote fetch only updates unrelated refs.
|
||||
try {
|
||||
await fetchRemoteForWorktreeCreate(provider, repo, 'origin')
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
const localBaseRefRefresh =
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase
|
||||
? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase)
|
||||
@@ -1944,7 +1977,10 @@ export async function createLocalWorktree(
|
||||
}
|
||||
} else {
|
||||
if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) {
|
||||
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], localGitExecOptions)
|
||||
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
|
||||
...localGitExecOptions,
|
||||
timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined)
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
|
||||
@@ -2135,7 +2171,13 @@ export async function createLocalWorktree(
|
||||
if (remoteTrackingRefresh) {
|
||||
await timing.time('refresh_base_ref', async () => {
|
||||
const result = await remoteTrackingRefresh.promise
|
||||
if (!result.ok) {
|
||||
if (!result.ok && !remoteTrackingRefresh.hadLocalBaseRef) {
|
||||
// Why: only block creation when the refresh failed AND there is no local
|
||||
// base ref to fall back on. An existing local remote-tracking ref lets
|
||||
// `git worktree add` proceed from a possibly stale but valid base, so a
|
||||
// transient offline/auth failure must not make the workspace
|
||||
// uncreatable. The compare-to-base view reflects any drift once the
|
||||
// remote is reachable again.
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
|
||||
)
|
||||
|
||||
@@ -3668,7 +3668,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create an SSH worktree when remote-tracking base refresh fails', async () => {
|
||||
it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -3679,6 +3679,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
// Empty rev-parse stdout -> no local remote-tracking base ref to fall back on.
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
@@ -3719,6 +3720,70 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => {
|
||||
// Regression: a failed SSH refresh must fall back to an existing local
|
||||
// remote-tracking base ref instead of blocking creation.
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
// Local remote-tracking base ref resolves -> usable fallback exists.
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
|
||||
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/remote/improve-dashboard',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
// The refresh must be ATTEMPTED (and fail) before falling back — guards
|
||||
// against a regression that skips the refresh whenever a local ref exists.
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'main',
|
||||
'refs/remotes/origin/main'
|
||||
)
|
||||
expect(provider.addWorktree).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
@@ -4205,7 +4270,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create when the pre-create remote-tracking refresh fails', async () => {
|
||||
it('creates from an existing local base ref when the pre-create refresh fails', async () => {
|
||||
// Regression: a failed refresh must not block creation when a usable local
|
||||
// remote-tracking base ref already exists.
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
@@ -4218,6 +4285,39 @@ describe('registerWorktreeHandlers', () => {
|
||||
ok: false,
|
||||
errorKind: 'git_error'
|
||||
})
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'created-sha',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})) as CreateWorktreeResult
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalled()
|
||||
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
|
||||
})
|
||||
|
||||
it('does not create when the pre-create refresh fails and no local base ref exists', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||||
ok: false,
|
||||
errorKind: 'git_error'
|
||||
})
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('./transcript-reader', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
import { isTextBlock } from '../../shared/native-chat-types'
|
||||
import {
|
||||
clearNativeChatTranscriptCache,
|
||||
readNativeChatTranscriptCached
|
||||
@@ -89,4 +90,62 @@ describe('readNativeChatTranscriptCached', () => {
|
||||
const result = await readNativeChatTranscriptCached('claude', 'absent')
|
||||
expect('error' in result && result.error).toBeTruthy()
|
||||
})
|
||||
|
||||
// Why: two worktrees can present the SAME (agent, sessionId) via different
|
||||
// transcript files — e.g. the same session resumed into a second worktree,
|
||||
// which writes a new transcript file. Keying the cache by sessionId let one
|
||||
// worktree's cached parse be served to the other whenever their file mtimes
|
||||
// coincided, leaking A's chat transcript into C's panel (#7326).
|
||||
it('never serves one file’s parse for a different file that shares a sessionId', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-xwt-'))
|
||||
tempRoots.push(root)
|
||||
const fileA = join(root, 'worktree-a.jsonl')
|
||||
const fileC = join(root, 'worktree-c.jsonl')
|
||||
await writeFile(
|
||||
fileA,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'a0',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'from-worktree-A' }
|
||||
}
|
||||
])
|
||||
)
|
||||
await writeFile(
|
||||
fileC,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'c0',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
message: { role: 'user', content: 'from-worktree-C' }
|
||||
}
|
||||
])
|
||||
)
|
||||
// Force IDENTICAL mtimes so a sessionId-only key's mtime guard cannot rescue
|
||||
// the collision — this is the intermittent, activity-driven case.
|
||||
const when = new Date('2026-06-01T10:00:00.000Z')
|
||||
await utimes(fileA, when, when)
|
||||
await utimes(fileC, when, when)
|
||||
|
||||
const readText = (result: Awaited<ReturnType<typeof readNativeChatTranscriptCached>>): string =>
|
||||
'messages' in result
|
||||
? result.messages
|
||||
.flatMap((message) => message.blocks)
|
||||
.filter(isTextBlock)
|
||||
.map((block) => block.text)
|
||||
.join(' ')
|
||||
: ''
|
||||
|
||||
// Same sessionId, different transcript files (worktree A resumed into C).
|
||||
const a = await readNativeChatTranscriptCached('claude', 'shared-session', fileA)
|
||||
const c = await readNativeChatTranscriptCached('claude', 'shared-session', fileC)
|
||||
|
||||
expect(readText(a)).toContain('from-worktree-A')
|
||||
expect(readText(c)).toContain('from-worktree-C')
|
||||
expect(readText(c)).not.toContain('from-worktree-A')
|
||||
// Distinct files must not share a cached parse object.
|
||||
expect(c).not.toBe(a)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,13 +4,18 @@ import { resolveSessionFilePath } from './session-file-resolver'
|
||||
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
|
||||
|
||||
// Why: both the desktop IPC handler and the runtime RPC handler read the same
|
||||
// host-filesystem transcript, so a single process-global cache keyed by
|
||||
// agent:sessionId maximizes the hit rate across desktop + every paired
|
||||
// web/mobile client. Keying by connection instead would defeat the multi-client
|
||||
// case this feature targets and multiply memory by the connection count.
|
||||
// The cache stores ONE canonical, unwindowed parse; windowing and per-surface
|
||||
// truncation stay in the callers so the same parse is reused across all `limit`
|
||||
// values and every client kind.
|
||||
// host-filesystem transcript, so a single process-global cache keyed by the
|
||||
// RESOLVED transcript file path maximizes the hit rate across desktop + every
|
||||
// paired web/mobile client (all clients of one session resolve the same path
|
||||
// against this runtime's home). Keying by connection instead would defeat the
|
||||
// multi-client case this feature targets and multiply memory by the connection
|
||||
// count. The key is the resolved file path, NOT `agent:sessionId`: two panes can
|
||||
// share one sessionId yet resolve to DIFFERENT files (the same session resumed
|
||||
// into a second worktree, which writes a new transcript file), and a
|
||||
// sessionId-only key let one worktree's cached parse be served to another when
|
||||
// their file mtimes momentarily coincided (#7326). The cache stores ONE
|
||||
// canonical, unwindowed parse; windowing and per-surface truncation stay in the
|
||||
// callers so the same parse is reused across all `limit` values and every client kind.
|
||||
|
||||
type CachedTranscript = {
|
||||
result: ReadTranscriptResult
|
||||
@@ -40,8 +45,8 @@ function setCached(key: string, value: CachedTranscript): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(agent: AgentType, sessionId: string): string {
|
||||
return `${agent}:${sessionId}`
|
||||
function cacheKey(agent: AgentType, filePath: string): string {
|
||||
return `${agent}:${filePath}`
|
||||
}
|
||||
|
||||
async function fileMtimeMs(filePath: string): Promise<number> {
|
||||
@@ -68,7 +73,7 @@ export async function readNativeChatTranscriptCached(
|
||||
return { error: `No transcript found for ${agent} session ${sessionId}` }
|
||||
}
|
||||
|
||||
const key = cacheKey(agent, sessionId)
|
||||
const key = cacheKey(agent, filePath)
|
||||
const mtimeMs = await fileMtimeMs(filePath)
|
||||
const cached = cache.get(key)
|
||||
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
|
||||
@@ -84,7 +89,7 @@ export async function readNativeChatTranscriptCached(
|
||||
return result
|
||||
}
|
||||
|
||||
/** Test-only: drop the per-session transcript cache between runs. */
|
||||
/** Test-only: drop the transcript parse cache between runs. */
|
||||
export function clearNativeChatTranscriptCache(): void {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ describe('LocalPtyProvider', () => {
|
||||
let exitCb: ((info: { exitCode: number }) => void) | undefined
|
||||
let origShell: string | undefined
|
||||
let origPowerlevelWizardDisable: string | undefined
|
||||
let origHistFile: string | undefined
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -105,8 +106,11 @@ describe('LocalPtyProvider', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
origShell = process.env.SHELL
|
||||
origPowerlevelWizardDisable = process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
|
||||
origHistFile = process.env.HISTFILE
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
delete process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD
|
||||
// injectHistoryEnv preserves an inherited HISTFILE, so clear it for hermetic history assertions.
|
||||
delete process.env.HISTFILE
|
||||
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 })
|
||||
@@ -158,6 +162,11 @@ describe('LocalPtyProvider', () => {
|
||||
} else {
|
||||
process.env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD = origPowerlevelWizardDisable
|
||||
}
|
||||
if (origHistFile === undefined) {
|
||||
delete process.env.HISTFILE
|
||||
} else {
|
||||
process.env.HISTFILE = origHistFile
|
||||
}
|
||||
})
|
||||
|
||||
describe('spawn', () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canonicalizeLocalTerminalPath,
|
||||
localTerminalCwdCanonicalizer
|
||||
} from './terminal-cwd-realpath'
|
||||
|
||||
describe('canonicalizeLocalTerminalPath', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-cwd-realpath-'))
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('resolves symlinks to their target', () => {
|
||||
const outside = join(root, 'outside')
|
||||
const worktree = join(root, 'worktree')
|
||||
mkdirSync(outside)
|
||||
mkdirSync(worktree)
|
||||
const link = join(worktree, 'escape')
|
||||
symlinkSync(outside, link, 'dir')
|
||||
expect(canonicalizeLocalTerminalPath(link)).toBe(canonicalizeLocalTerminalPath(outside))
|
||||
})
|
||||
|
||||
it('returns null for nonexistent paths', () => {
|
||||
expect(canonicalizeLocalTerminalPath(join(root, 'does-not-exist'))).toBeNull()
|
||||
})
|
||||
|
||||
it('skips WSL UNC paths instead of touching the 9P share', () => {
|
||||
expect(canonicalizeLocalTerminalPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toBeNull()
|
||||
expect(canonicalizeLocalTerminalPath('//wsl$/Ubuntu/home/jin/repo')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('localTerminalCwdCanonicalizer', () => {
|
||||
it('is disabled for SSH connections and enabled locally', () => {
|
||||
expect(localTerminalCwdCanonicalizer('ssh-1')).toBeUndefined()
|
||||
expect(localTerminalCwdCanonicalizer(null)).toBe(canonicalizeLocalTerminalPath)
|
||||
expect(localTerminalCwdCanonicalizer(undefined)).toBe(canonicalizeLocalTerminalPath)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
// Why: terminal startup cwd containment is string-based; realpath closes the
|
||||
// symlink escape for local paths. Only for local worktrees — SSH/remote paths
|
||||
// are not resolvable on this filesystem.
|
||||
export function canonicalizeLocalTerminalPath(targetPath: string): string | null {
|
||||
// Why: realpath over the WSL 9P share is unreliable and can block the main
|
||||
// process while the distro boots; keep the string containment check only.
|
||||
if (isWslUncPath(targetPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return realpathSync.native(targetPath)
|
||||
} catch {
|
||||
try {
|
||||
// Why: realpathSync.native can fail on network/UNC mounts where the
|
||||
// JS implementation still succeeds (same fallback as git repo scanning).
|
||||
return realpathSync(targetPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function localTerminalCwdCanonicalizer(
|
||||
connectionId: string | null | undefined
|
||||
): ((path: string) => string | null) | undefined {
|
||||
return connectionId ? undefined : canonicalizeLocalTerminalPath
|
||||
}
|
||||
@@ -36,6 +36,12 @@ function exactBaseRefreshOptions(cwd: string): {
|
||||
return { cwd, timeout: 60_000, useConfiguredSshCommandForNetwork: true }
|
||||
}
|
||||
|
||||
// Why (STA-1292): the broad create-path fetch must carry a timeout so a Windows
|
||||
// credential-manager GUI hang can't wedge worktree creation forever.
|
||||
function fullRemoteFetchOptions(cwd: string): { cwd: string; timeout: number } {
|
||||
return { cwd, timeout: 60_000 }
|
||||
}
|
||||
|
||||
function mockFetchResults(results: (Promise<unknown> | unknown)[]): void {
|
||||
let fetchIndex = 0
|
||||
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
|
||||
@@ -299,7 +305,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
exactBaseRefreshOptions('/repo/h')
|
||||
],
|
||||
[['fetch', 'origin'], { cwd: '/repo/h' }]
|
||||
[['fetch', 'origin'], fullRemoteFetchOptions('/repo/h')]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -341,7 +347,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
|
||||
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
|
||||
)
|
||||
expect(fetchCalls).toEqual([
|
||||
[['fetch', 'origin'], { cwd: '/repo/i' }],
|
||||
[['fetch', 'origin'], fullRemoteFetchOptions('/repo/i')],
|
||||
[
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
exactBaseRefreshOptions('/repo/i')
|
||||
@@ -387,7 +393,7 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
|
||||
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
|
||||
)
|
||||
expect(fetchCalls).toEqual([
|
||||
[['fetch', 'origin'], { cwd: '/repo/i-fail' }],
|
||||
[['fetch', 'origin'], fullRemoteFetchOptions('/repo/i-fail')],
|
||||
[
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
exactBaseRefreshOptions('/repo/i-fail')
|
||||
|
||||
@@ -176,6 +176,9 @@ const {
|
||||
listGitHubAssignableUsersMock,
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
markCodexProjectTrustedMock,
|
||||
markCopilotFolderTrustedMock,
|
||||
markCursorWorkspaceTrustedMock,
|
||||
listGitLabMergeRequestsMock,
|
||||
listGitLabWorkItemsMock,
|
||||
listGitLabIssuesMock,
|
||||
@@ -267,6 +270,9 @@ const {
|
||||
listGitHubAssignableUsersMock: vi.fn(),
|
||||
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
|
||||
detectRemoteAgentsMock: vi.fn(),
|
||||
markCodexProjectTrustedMock: vi.fn(),
|
||||
markCopilotFolderTrustedMock: vi.fn(),
|
||||
markCursorWorkspaceTrustedMock: vi.fn(),
|
||||
listGitLabMergeRequestsMock: vi.fn(),
|
||||
listGitLabWorkItemsMock: vi.fn(),
|
||||
listGitLabIssuesMock: vi.fn(),
|
||||
@@ -333,6 +339,12 @@ vi.mock('../ipc/preflight', () => ({
|
||||
detectRemoteAgents: detectRemoteAgentsMock
|
||||
}))
|
||||
|
||||
vi.mock('../agent-trust-presets', () => ({
|
||||
markCodexProjectTrusted: markCodexProjectTrustedMock,
|
||||
markCopilotFolderTrusted: markCopilotFolderTrustedMock,
|
||||
markCursorWorkspaceTrusted: markCursorWorkspaceTrustedMock
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
buildPosixRunnerScript: (script: string) => `#!/usr/bin/env bash\nset -e\n${script}\n`,
|
||||
buildWindowsRunnerScript: (script: string) => `@echo off\r\n${script}\r\n`,
|
||||
@@ -641,6 +653,9 @@ function resetRuntimeTestMocks(): void {
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
|
||||
detectRemoteAgentsMock.mockReset()
|
||||
detectRemoteAgentsMock.mockResolvedValue([])
|
||||
markCodexProjectTrustedMock.mockReset()
|
||||
markCopilotFolderTrustedMock.mockReset()
|
||||
markCursorWorkspaceTrustedMock.mockReset()
|
||||
listGitLabMergeRequestsMock.mockReset()
|
||||
listGitLabMergeRequestsMock.mockResolvedValue({ items: [] })
|
||||
listGitLabWorkItemsMock.mockReset()
|
||||
@@ -2355,10 +2370,21 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => {
|
||||
it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => {
|
||||
// Regression: an offline/auth failure while refreshing the remote-tracking
|
||||
// base must not block creation when a usable local base ref already exists.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-refresh-fails')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-refresh-fails')
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/cli-refresh-fails',
|
||||
head: 'base-sha',
|
||||
branch: 'cli-refresh-fails',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
|
||||
vi.mocked(addWorktree).mockResolvedValueOnce({})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
@@ -2366,6 +2392,7 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
// Local remote-tracking base ref resolves -> usable fallback exists.
|
||||
if (args[0] === 'rev-parse' && args[1] === '--verify') {
|
||||
return { stdout: 'base-sha\n', stderr: '' }
|
||||
}
|
||||
@@ -2380,6 +2407,40 @@ describe('OrcaRuntimeService', () => {
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'cli-refresh-fails'
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(addWorktree).toHaveBeenCalled()
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create a runtime local worktree when the refresh fails and no local base ref exists', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-refresh-no-local')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-refresh-no-local')
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
// No local remote-tracking base ref -> nothing to fall back on.
|
||||
if (args[0] === 'rev-parse' && args[1] === '--verify') {
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'cli-refresh-no-local'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
|
||||
)
|
||||
@@ -4819,7 +4880,10 @@ describe('OrcaRuntimeService', () => {
|
||||
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
||||
wslGitOptions
|
||||
)
|
||||
expect(asyncGitSpy).toHaveBeenCalledWith(['fetch', 'origin'], wslGitOptions)
|
||||
expect(asyncGitSpy).toHaveBeenCalledWith(['fetch', 'origin'], {
|
||||
...wslGitOptions,
|
||||
timeout: 60_000
|
||||
})
|
||||
expect(syncGitSpy).toHaveBeenCalledWith(
|
||||
['rev-list', '--left-right', '--count', 'HEAD...origin/main'],
|
||||
{ cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' }
|
||||
@@ -6982,6 +7046,204 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applies Settings agent defaults to bare agent command terminal creates', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
|
||||
agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex',
|
||||
title: 'worker'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as
|
||||
| { command?: string; env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnCall?.command).toBe("codex '--dangerously-bypass-approvals-and-sandbox'")
|
||||
expect(spawnCall?.env).toMatchObject({
|
||||
CODEX_PROFILE: 'captured',
|
||||
ORCA_WORKTREE_ID: TEST_WORKTREE_ID
|
||||
})
|
||||
expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(UUID_RE)
|
||||
expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH)
|
||||
expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
spawn.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('matches canonical bare agent commands when a command override is configured', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: { codex: 'codex --profile work' },
|
||||
agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined
|
||||
expect(spawnCall?.command).toBe(
|
||||
"codex --profile work '--dangerously-bypass-approvals-and-sandbox'"
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps non-bare agent command terminal creates unchanged', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex exec summarize'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as
|
||||
| { command?: string; env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnCall?.command).toBe('codex exec summarize')
|
||||
expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps disabled bare agent command terminal creates unchanged', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: ['codex' as const],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as
|
||||
| { command?: string; env?: Record<string, string> }
|
||||
| undefined
|
||||
expect(spawnCall?.command).toBe('codex')
|
||||
expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends Settings agent defaults through renderer-backed bare agent terminal creates', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
|
||||
agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
|
||||
const webContents = { send: vi.fn() }
|
||||
webContents.send.mockImplementation((_channel: string, payload: { requestId: string }) => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-renderer',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-renderer',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-renderer', title: 'Codex' }
|
||||
)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex',
|
||||
rendererBacked: true
|
||||
})
|
||||
|
||||
expect(webContents.send).toHaveBeenCalledWith(
|
||||
'terminal:requestTabCreate',
|
||||
expect.objectContaining({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
env: { CODEX_PROFILE: 'captured' },
|
||||
launchAgent: 'codex',
|
||||
launchConfig: {
|
||||
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
|
||||
agentEnv: { CODEX_PROFILE: 'captured' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(markCodexProjectTrustedMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH)
|
||||
expect(markCodexProjectTrustedMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
webContents.send.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('injects runtime hook receiver env into terminal sessions', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooked' })
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
@@ -7197,7 +7459,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(compoundClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
|
||||
expect(compoundClaude.env?.TMUX).toBeUndefined()
|
||||
|
||||
expect(normalAgent.command).toBe('codex')
|
||||
expect(normalAgent.command).toBe("codex '--dangerously-bypass-approvals-and-sandbox'")
|
||||
expect(normalAgent.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
|
||||
expect(normalAgent.env?.TMUX).toBeUndefined()
|
||||
})
|
||||
@@ -7556,7 +7818,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'codex',
|
||||
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
@@ -7625,7 +7887,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.objectContaining({
|
||||
requestId: expect.any(String),
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
command: 'codex',
|
||||
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
|
||||
title: 'Renderer Terminal'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -186,7 +186,11 @@ import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
getTuiAgentLaunchCommand,
|
||||
isTuiAgent,
|
||||
TUI_AGENT_CONFIG
|
||||
} from '../../shared/tui-agent-config'
|
||||
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
|
||||
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
|
||||
import {
|
||||
@@ -202,6 +206,7 @@ import {
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
|
||||
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
@@ -943,6 +948,32 @@ type RuntimePtyWorktreeRecord = {
|
||||
waitBlockedAt: number | null
|
||||
}
|
||||
|
||||
type TerminalCreateOptions = {
|
||||
command?: string
|
||||
claudeAgentTeamsSourceCommand?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: WorktreeStartupLaunch['launchConfig']
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
telemetry?: WorktreeStartupLaunch['telemetry']
|
||||
title?: string
|
||||
focus?: boolean
|
||||
rendererBacked?: boolean
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
sessionId?: string
|
||||
persistHostSessionBinding?: boolean
|
||||
// Why: the headless mobile-session create publishes its own authoritative
|
||||
// snapshot (with the correct target group) right after spawn. Skip the
|
||||
// intermediate pty-backed publish so the new tab doesn't briefly flash in
|
||||
// the wrong (active) group before the corrected snapshot lands.
|
||||
deferMobileSessionPublish?: boolean
|
||||
}
|
||||
|
||||
type PtyForegroundAgentRefresh = {
|
||||
promise: Promise<boolean>
|
||||
startedAfterTitleObservation: number
|
||||
@@ -959,6 +990,44 @@ function copySleepingAgentLaunchConfig(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgentLaunchCommandForMatch(command: string): string {
|
||||
return command.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function resolveBareAgentLaunchCommand(args: {
|
||||
command: string | undefined
|
||||
settings: {
|
||||
agentCmdOverrides?: Partial<Record<TuiAgent, string>> | null
|
||||
disabledTuiAgents?: Iterable<unknown> | null
|
||||
}
|
||||
platform: NodeJS.Platform
|
||||
isRemote: boolean
|
||||
}): TuiAgent | null {
|
||||
const command = args.command ? normalizeAgentLaunchCommandForMatch(args.command) : ''
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cmdOverrides = args.settings.agentCmdOverrides ?? {}
|
||||
for (const agent of Object.keys(TUI_AGENT_CONFIG) as TuiAgent[]) {
|
||||
if (!isTuiAgentEnabled(agent, args.settings.disabledTuiAgents)) {
|
||||
continue
|
||||
}
|
||||
const override = cmdOverrides[agent]?.trim()
|
||||
const defaultLaunchCommand = getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], args.platform, {
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
const launchCommands = override ? [defaultLaunchCommand, override] : [defaultLaunchCommand]
|
||||
if (
|
||||
launchCommands.some((candidate) => command === normalizeAgentLaunchCommandForMatch(candidate))
|
||||
) {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function inferCapturedClaudeAgentTeamsMode(
|
||||
launchConfig: SleepingAgentLaunchConfig | undefined,
|
||||
command: string | undefined,
|
||||
@@ -13075,7 +13144,13 @@ export class OrcaRuntimeService {
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
if (!refreshResult.ok) {
|
||||
if (!refreshResult.ok && !hadLocalBaseRef) {
|
||||
// Why: only block creation when the refresh failed AND there is no
|
||||
// usable local base ref to fall back on. If a local remote-tracking ref
|
||||
// already exists, `git worktree add` can create from it — a possibly
|
||||
// stale but valid base — so a transient offline/auth failure must not
|
||||
// make the workspace uncreatable. The compare-to-base view reflects any
|
||||
// drift once the remote is reachable again.
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
@@ -14000,7 +14075,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const promise = this.enqueueRemoteFetch(key, () =>
|
||||
gitExecFileAsync(['fetch', remote], { cwd: repoPath, ...gitOptions })
|
||||
gitExecFileAsync(['fetch', remote], {
|
||||
cwd: repoPath,
|
||||
...gitOptions,
|
||||
// Why: cap the create-path base-ref fetch so a stuck first-auth on
|
||||
// Windows (GCM prompt) fails fast instead of hanging creation (STA-1292).
|
||||
timeout: REMOTE_FETCH_TIMEOUT_MS
|
||||
})
|
||||
.then((): RemoteFetchResult => {
|
||||
// Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection
|
||||
// would make the freshness cache lie about the last known remote state.
|
||||
@@ -14059,7 +14140,7 @@ export class OrcaRuntimeService {
|
||||
// Why: exact remote-base refresh is the network gate for worktree
|
||||
// creation, so honor repo SSH routing and bound custom wrappers.
|
||||
useConfiguredSshCommandForNetwork: true,
|
||||
timeout: 60_000
|
||||
timeout: REMOTE_FETCH_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
.then((): RemoteFetchResult => {
|
||||
@@ -15438,33 +15519,71 @@ export class OrcaRuntimeService {
|
||||
return { handle, tabId: leaf.tabId, title }
|
||||
}
|
||||
|
||||
private async resolveAgentTerminalCreateOptions(
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
opts: TerminalCreateOptions
|
||||
): Promise<TerminalCreateOptions> {
|
||||
// Why: raw shell commands like `codex exec` must remain user-authored shell.
|
||||
// Only unmanaged, repo-backed, bare agent launches get Settings defaults.
|
||||
if (
|
||||
!opts.command ||
|
||||
opts.env ||
|
||||
opts.launchConfig ||
|
||||
opts.launchAgent ||
|
||||
opts.startupCommandDelivery ||
|
||||
opts.claudeAgentTeamsSourceCommand ||
|
||||
!workspace.repo ||
|
||||
!this.store
|
||||
) {
|
||||
return opts
|
||||
}
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
|
||||
const isRemote = repoIsRemote(workspace.repo)
|
||||
const agent = resolveBareAgentLaunchCommand({
|
||||
command: opts.command,
|
||||
settings,
|
||||
platform,
|
||||
isRemote
|
||||
})
|
||||
if (!agent) {
|
||||
return opts
|
||||
}
|
||||
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: '',
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
platform,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
if (!startupPlan) {
|
||||
return opts
|
||||
}
|
||||
|
||||
if (workspace.connectionId) {
|
||||
await this.markRemoteWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path)
|
||||
} else {
|
||||
this.markLocalWorkspaceTrustedForAgent(agent, workspace.path)
|
||||
}
|
||||
|
||||
return {
|
||||
...opts,
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
launchAgent: agent,
|
||||
startupCommandDelivery: startupPlan.startupCommandDelivery
|
||||
}
|
||||
}
|
||||
|
||||
async createTerminal(
|
||||
worktreeSelector?: string,
|
||||
opts: {
|
||||
command?: string
|
||||
claudeAgentTeamsSourceCommand?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: WorktreeStartupLaunch['launchConfig']
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
telemetry?: WorktreeStartupLaunch['telemetry']
|
||||
title?: string
|
||||
focus?: boolean
|
||||
rendererBacked?: boolean
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
sessionId?: string
|
||||
persistHostSessionBinding?: boolean
|
||||
// Why: the headless mobile-session create publishes its own authoritative
|
||||
// snapshot (with the correct target group) right after spawn. Skip the
|
||||
// intermediate pty-backed publish so the new tab doesn't briefly flash in
|
||||
// the wrong (active) group before the corrected snapshot lands.
|
||||
deferMobileSessionPublish?: boolean
|
||||
} = {}
|
||||
opts: TerminalCreateOptions = {}
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
const presentation = resolveTerminalPresentation(opts)
|
||||
const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true
|
||||
@@ -15487,7 +15606,9 @@ export class OrcaRuntimeService {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path
|
||||
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
|
||||
const cwd =
|
||||
this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) ?? workspace.path
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
// Why: mint tabId in main before spawn so paneKey is known at PTY env
|
||||
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
|
||||
@@ -15495,25 +15616,27 @@ export class OrcaRuntimeService {
|
||||
// hook payload arrives with an empty paneKey and the renderer cannot
|
||||
// attribute the event. Use a stable UUID leaf because hooks reject the
|
||||
// legacy numeric pane keys after the pane-id migration.
|
||||
const hintedTabId = opts.tabId?.trim()
|
||||
const hintedTabId = launchOpts.tabId?.trim()
|
||||
const canAdoptPaneIdentity =
|
||||
hintedTabId !== undefined &&
|
||||
isValidHostTerminalTabId(hintedTabId) &&
|
||||
opts.leafId !== undefined &&
|
||||
isTerminalLeafId(opts.leafId)
|
||||
launchOpts.leafId !== undefined &&
|
||||
isTerminalLeafId(launchOpts.leafId)
|
||||
const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
|
||||
const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID()
|
||||
const leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID()
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const launchToken = opts.launchConfig ? (opts.launchToken ?? randomUUID()) : undefined
|
||||
const launchToken = launchOpts.launchConfig
|
||||
? (launchOpts.launchToken ?? randomUUID())
|
||||
: undefined
|
||||
const baseEnv = {
|
||||
...opts.env,
|
||||
...launchOpts.env,
|
||||
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
|
||||
}
|
||||
const claudeAgentTeamsSourceCommand =
|
||||
opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined
|
||||
launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined
|
||||
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
|
||||
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
|
||||
opts.launchConfig,
|
||||
launchOpts.launchConfig,
|
||||
claudeAgentTeamsSourceCommand,
|
||||
claudeAgentTeamsMode
|
||||
)
|
||||
@@ -15538,25 +15661,25 @@ export class OrcaRuntimeService {
|
||||
const sequencedStartupCommand =
|
||||
agentTeamsPlan &&
|
||||
claudeAgentTeamsSourceCommand &&
|
||||
opts.command &&
|
||||
claudeAgentTeamsSourceCommand !== opts.command
|
||||
launchOpts.command &&
|
||||
claudeAgentTeamsSourceCommand !== launchOpts.command
|
||||
? agentTeamsPlan.command
|
||||
: undefined
|
||||
const effectiveLaunchConfig =
|
||||
opts.launchConfig && agentTeamsPlan
|
||||
launchOpts.launchConfig && agentTeamsPlan
|
||||
? {
|
||||
...opts.launchConfig,
|
||||
agentCommand: opts.launchConfig.agentCommand
|
||||
...launchOpts.launchConfig,
|
||||
agentCommand: launchOpts.launchConfig.agentCommand
|
||||
? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32'
|
||||
? addClaudeTeammateModeInProcess(opts.launchConfig.agentCommand)
|
||||
: addClaudeTeammateModeAuto(opts.launchConfig.agentCommand)
|
||||
? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand)
|
||||
: addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand)
|
||||
: agentTeamsPlan.command,
|
||||
agentEnv: {
|
||||
...opts.launchConfig.agentEnv,
|
||||
...launchOpts.launchConfig.agentEnv,
|
||||
...agentTeamsPlan.env
|
||||
}
|
||||
}
|
||||
: opts.launchConfig
|
||||
: launchOpts.launchConfig
|
||||
// Why: setup/agent sequencing wraps the PTY launch in a wait shell before
|
||||
// Claude Agent Teams runs. Preserve the direct Claude command separately
|
||||
// so the wrapper can exec the teammate-mode variant after setup completes.
|
||||
@@ -15576,29 +15699,31 @@ export class OrcaRuntimeService {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command),
|
||||
command: sequencedStartupCommand
|
||||
? launchOpts.command
|
||||
: (agentTeamsPlan?.command ?? launchOpts.command),
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
env,
|
||||
envToDelete: agentTeamsPlan?.envToDelete,
|
||||
telemetry: opts.telemetry,
|
||||
telemetry: launchOpts.telemetry,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId,
|
||||
...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
|
||||
...(opts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
|
||||
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
|
||||
...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
|
||||
})
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId)
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
if (opts.title) {
|
||||
if (launchOpts.title) {
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.title = opts.title
|
||||
pty.title = launchOpts.title
|
||||
pty.titleUpdatedAt = observedAt
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, opts.title, observedAt)
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
|
||||
} else {
|
||||
pty.title = null
|
||||
pty.titleUpdatedAt = null
|
||||
@@ -15609,14 +15734,14 @@ export class OrcaRuntimeService {
|
||||
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
|
||||
: null
|
||||
pty.launchToken = launchToken ?? null
|
||||
pty.launchAgent = opts.launchAgent ?? null
|
||||
pty.launchAgent = launchOpts.launchAgent ?? null
|
||||
}
|
||||
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
|
||||
if (pty && opts.deferMobileSessionPublish !== true) {
|
||||
if (pty && launchOpts.deferMobileSessionPublish !== true) {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
|
||||
tabId,
|
||||
leafId,
|
||||
title: opts.title ?? null,
|
||||
title: launchOpts.title ?? null,
|
||||
activate: presentation === 'focused',
|
||||
// Why: explicit background presentation may carry legacy activate
|
||||
// metadata from an already-owned renderer pane; don't select it on mobile.
|
||||
@@ -15634,11 +15759,11 @@ export class OrcaRuntimeService {
|
||||
// already baked into the PTY env — keeps paneKey hook attribution intact.
|
||||
await this.notifier.revealTerminalSession(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: opts.title ?? null,
|
||||
title: launchOpts.title ?? null,
|
||||
...(cwd !== workspace.path ? { cwd } : {}),
|
||||
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
tabId,
|
||||
@@ -15658,7 +15783,7 @@ export class OrcaRuntimeService {
|
||||
paneKey,
|
||||
ptyId: result.id,
|
||||
worktreeId: workspace.id,
|
||||
title: opts.title ?? null,
|
||||
title: launchOpts.title ?? null,
|
||||
surface,
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
@@ -15671,8 +15796,13 @@ export class OrcaRuntimeService {
|
||||
const workspace = worktreeSelector
|
||||
? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
: null
|
||||
const launchOpts = workspace
|
||||
? await this.resolveAgentTerminalCreateOptions(workspace, opts)
|
||||
: opts
|
||||
const worktreeId = workspace?.id
|
||||
const cwd = workspace ? resolveTerminalStartupCwd(workspace.path, opts.cwd) : opts.cwd
|
||||
const cwd = workspace
|
||||
? this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd)
|
||||
: launchOpts.cwd
|
||||
const requestId = randomUUID()
|
||||
|
||||
// Why: terminal creation is a renderer-side Zustand store operation (like
|
||||
@@ -15696,21 +15826,21 @@ export class OrcaRuntimeService {
|
||||
if (r.error) {
|
||||
reject(new Error(r.error))
|
||||
} else {
|
||||
resolve({ tabId: r.tabId!, title: r.title ?? opts.title ?? '' })
|
||||
resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' })
|
||||
}
|
||||
}
|
||||
ipcMain.on('terminal:tabCreateReply', handler)
|
||||
win.webContents.send('terminal:requestTabCreate', {
|
||||
requestId,
|
||||
worktreeId,
|
||||
command: opts.command,
|
||||
command: launchOpts.command,
|
||||
cwd,
|
||||
...(opts.env ? { env: opts.env } : {}),
|
||||
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
|
||||
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
title: opts.title,
|
||||
...(launchOpts.env ? { env: launchOpts.env } : {}),
|
||||
...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
|
||||
...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
title: launchOpts.title,
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {})
|
||||
})
|
||||
@@ -15815,7 +15945,7 @@ export class OrcaRuntimeService {
|
||||
this.assertGraphReady()
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const worktreeId = workspace.id
|
||||
const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd)
|
||||
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
let afterDesktopTabId: string | undefined
|
||||
if (opts.afterTabId) {
|
||||
@@ -16019,7 +16149,7 @@ export class OrcaRuntimeService {
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd)
|
||||
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
|
||||
// Why: SshPtyProvider treats sessionId as a relay reattach request. Only
|
||||
// synthesize local serve ids; SSH fresh terminals must call pty.spawn.
|
||||
const stableSessionId =
|
||||
@@ -16888,6 +17018,17 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: every terminal-creation path must apply the same symlink-aware cwd
|
||||
// guard; keep the canonicalizer wiring in one place.
|
||||
private resolveGuardedWorkspaceTerminalCwd(
|
||||
workspace: Pick<TerminalWorkspaceLaunchScope, 'path' | 'connectionId'>,
|
||||
requestedCwd?: string | null
|
||||
): string | undefined {
|
||||
return resolveTerminalStartupCwd(workspace.path, requestedCwd, {
|
||||
canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId)
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveTerminalWorkspaceLaunchScope(
|
||||
selector: string
|
||||
): Promise<TerminalWorkspaceLaunchScope> {
|
||||
@@ -22385,6 +22526,10 @@ const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000
|
||||
// clicks and successive coordinator dispatches feel snappy, while still being
|
||||
// short enough that a genuinely-changed remote is observed on the next action.
|
||||
const FETCH_FRESHNESS_MS = 30_000
|
||||
// Why: bound create-path remote fetches so a Windows credential-manager GUI hang
|
||||
// (STA-1292) can't wedge worktree creation forever; parity with the exact-base
|
||||
// refresh sibling's timeout.
|
||||
const REMOTE_FETCH_TIMEOUT_MS = 60_000
|
||||
const REMOTE_FETCH_CACHE_MAX = 512
|
||||
const DRIFT_PROBE_SUBJECT_LIMIT = 5
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type MockSshClient = {
|
||||
_sock: Socket | undefined
|
||||
lastExecCommand?: string
|
||||
lastConnectConfig?: unknown
|
||||
exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void
|
||||
}
|
||||
let clientInstances: MockSshClient[] = []
|
||||
|
||||
@@ -707,6 +708,139 @@ describe('SshConnection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a session-limit-refused exec open and succeeds on a later attempt', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
const channel = { close: vi.fn() }
|
||||
const execMock = vi
|
||||
.fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
|
||||
.mockImplementationOnce((_cmd, cb) => {
|
||||
cb(
|
||||
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
.mockImplementation((_cmd, cb) => cb(undefined, channel))
|
||||
clientInstances[0].exec = execMock as never
|
||||
|
||||
await expect(conn.exec('printf ready')).resolves.toBe(channel)
|
||||
expect(execMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces the session-limit error once open retries are exhausted', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
const refusal = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 2
|
||||
})
|
||||
const execMock = vi
|
||||
.fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
|
||||
.mockImplementation((_cmd, cb) => cb(refusal, undefined))
|
||||
clientInstances[0].exec = execMock as never
|
||||
|
||||
await expect(conn.exec('printf ready')).rejects.toBe(refusal)
|
||||
expect(execMock).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('does not retry non-session-limit exec open failures', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
const failure = new Error('Not connected')
|
||||
const execMock = vi
|
||||
.fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
|
||||
.mockImplementation((_cmd, cb) => cb(failure, undefined))
|
||||
clientInstances[0].exec = execMock as never
|
||||
|
||||
await expect(conn.exec('printf ready')).rejects.toBe(failure)
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds an aborted exec to the close grace when ssh2 never invokes the open callback', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
execBehavior = 'pending'
|
||||
const controller = new AbortController()
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const outcomePromise = conn
|
||||
.exec('printf ready', { signal: controller.signal })
|
||||
.then(() => 'opened')
|
||||
.catch((error: Error) => error.name)
|
||||
|
||||
controller.abort()
|
||||
// Why: a hung socket must not pin the aborted caller for the full 30s
|
||||
// connect timeout — the abort settles at the 5s grace bound instead.
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
await expect(outcomePromise).resolves.toBe('AbortError')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects without waiting out the backoff when aborted during a session-limit retry delay', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
const controller = new AbortController()
|
||||
const refusal = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 2
|
||||
})
|
||||
const execMock = vi
|
||||
.fn<(cmd: string, cb: (err: Error | undefined, ch: unknown) => void) => void>()
|
||||
.mockImplementation((_cmd, cb) => cb(refusal, undefined))
|
||||
clientInstances[0].exec = execMock as never
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const outcomePromise = conn
|
||||
.exec('printf ready', { signal: controller.signal })
|
||||
.then(() => 'opened')
|
||||
.catch((error: Error) => error.name)
|
||||
|
||||
// Flush microtasks so the first refused attempt lands in the backoff.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.abort()
|
||||
|
||||
// No timer advance: the abort alone must release the backoff delay.
|
||||
await expect(outcomePromise).resolves.toBe('AbortError')
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles an abort during channel open only after the late channel closes', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
execBehavior = 'pending'
|
||||
const controller = new AbortController()
|
||||
const lateChannel = Object.assign(new EventEmitter(), {
|
||||
close: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
stderr: { resume: vi.fn() }
|
||||
})
|
||||
|
||||
const outcomePromise = conn
|
||||
.exec('printf ready', { signal: controller.signal })
|
||||
.then(() => 'opened')
|
||||
.catch((error: Error) => error.name)
|
||||
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
pendingExecCallback?.(undefined, lateChannel)
|
||||
|
||||
// Why: the sshd session slot is freed only when the channel finishes
|
||||
// closing — settling before 'close' lets the next open race the close.
|
||||
const early = await Promise.race([outcomePromise, Promise.resolve('pending')])
|
||||
expect(early).toBe('pending')
|
||||
expect(lateChannel.close).toHaveBeenCalledTimes(1)
|
||||
expect(lateChannel.resume).toHaveBeenCalled()
|
||||
|
||||
lateChannel.emit('close')
|
||||
await expect(outcomePromise).resolves.toBe('AbortError')
|
||||
})
|
||||
|
||||
it('times out when ssh2 never opens an SFTP channel', async () => {
|
||||
const conn = new SshConnection(createTarget(), createCallbacks())
|
||||
await conn.connect()
|
||||
|
||||
+124
-17
@@ -35,12 +35,25 @@ import {
|
||||
type SshConnectionCallbacks
|
||||
} from './ssh-connection-utils'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { isSshSessionLimitError } from './ssh-session-limit-error'
|
||||
export type { SshConnectionCallbacks } from './ssh-connection-utils'
|
||||
|
||||
type SshRemoteFileOptions = {
|
||||
hostPlatform?: RemoteHostPlatform
|
||||
}
|
||||
|
||||
// Upper bound on waiting, after an abort, for the in-flight open callback or
|
||||
// for an aborted late-opened channel to finish closing before rejecting
|
||||
// anyway. Normal opens and closes complete in one network round-trip.
|
||||
const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000
|
||||
|
||||
// Why: on session-limited servers (MaxSessions), a channel open can be refused
|
||||
// transiently — e.g. our next CHANNEL_OPEN reaching sshd a few microseconds
|
||||
// before it finishes processing the previous channel's close. A refused open
|
||||
// never started the command, so retrying is always safe.
|
||||
const SESSION_LIMIT_OPEN_RETRIES = 4
|
||||
const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150
|
||||
|
||||
function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null {
|
||||
if (!config) {
|
||||
return null
|
||||
@@ -132,10 +145,14 @@ export class SshConnection {
|
||||
}
|
||||
const client = this.client
|
||||
const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd)
|
||||
return this.waitForSshCallback(
|
||||
'SSH exec channel timed out',
|
||||
(callback) => client.exec(remoteCommand, callback),
|
||||
(channel) => channel.close(),
|
||||
return this.openSessionChannelWithRetry(
|
||||
() =>
|
||||
this.waitForSshCallback(
|
||||
'SSH exec channel timed out',
|
||||
(callback) => client.exec(remoteCommand, callback),
|
||||
(channel) => channel.close(),
|
||||
options?.signal
|
||||
),
|
||||
options?.signal
|
||||
)
|
||||
}
|
||||
@@ -148,13 +165,50 @@ export class SshConnection {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
const client = this.client
|
||||
return this.waitForSshCallback(
|
||||
'SSH SFTP channel timed out',
|
||||
(callback) => client.sftp(callback),
|
||||
(sftp) => sftp.end()
|
||||
return this.openSessionChannelWithRetry(() =>
|
||||
this.waitForSshCallback(
|
||||
'SSH SFTP channel timed out',
|
||||
(callback) => client.sftp(callback),
|
||||
(sftp) => sftp.end()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private async openSessionChannelWithRetry<T>(
|
||||
open: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
// Why: an abort must release the backoff immediately, not after it.
|
||||
if (!signal?.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const onDelayDone = (): void => {
|
||||
clearTimeout(delayTimer)
|
||||
signal?.removeEventListener('abort', onDelayDone)
|
||||
resolve()
|
||||
}
|
||||
const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS)
|
||||
signal?.addEventListener('abort', onDelayDone, { once: true })
|
||||
})
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw createSshOperationAbortError()
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await open()
|
||||
} catch (err) {
|
||||
if (!isSshSessionLimitError(err)) {
|
||||
throw err
|
||||
}
|
||||
lastError = err
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private waitForSshCallback<T>(
|
||||
timeoutMessage: string,
|
||||
register: (callback: (error: Error | undefined, value: T) => void) => void,
|
||||
@@ -163,23 +217,66 @@ export class SshConnection {
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
// Why: rejecting the instant the signal aborts lets the caller proceed
|
||||
// while the in-flight channel open completes in the background and holds
|
||||
// a server-side session slot (MaxSessions). Mark the abort and settle
|
||||
// from the open callback, after the late resource has been closed.
|
||||
let abortRequested = false
|
||||
let abortDeadlineTimer: NodeJS.Timeout | undefined
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(abortDeadlineTimer)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(createSshOperationAbortError())
|
||||
abortRequested = true
|
||||
// Why: a hung socket may never invoke the open callback — bound the
|
||||
// aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS.
|
||||
abortDeadlineTimer = setTimeout(() => {
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(createSshOperationAbortError())
|
||||
}, ABORTED_CHANNEL_CLOSE_GRACE_MS)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
settled = true
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reject(new Error(timeoutMessage))
|
||||
cleanup()
|
||||
reject(abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage))
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
const rejectAfterClose = (value: T): void => {
|
||||
const abortError = createSshOperationAbortError()
|
||||
const emitter = value as Partial<NodeJS.EventEmitter> & {
|
||||
resume?: () => void
|
||||
stderr?: { resume?: () => void }
|
||||
}
|
||||
let finished = false
|
||||
const done = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
clearTimeout(closeGraceTimer)
|
||||
reject(abortError)
|
||||
}
|
||||
// Why: bounded — a remote that never confirms the close must not hang
|
||||
// the aborted operation forever.
|
||||
const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS)
|
||||
if (typeof emitter.once === 'function') {
|
||||
emitter.once('close', done)
|
||||
}
|
||||
// Why: ssh2 withholds the 'close' event until the channel's streams
|
||||
// are drained; nobody else will ever read this discarded channel.
|
||||
emitter.resume?.()
|
||||
emitter.stderr?.resume?.()
|
||||
try {
|
||||
cleanupLateValue?.(value)
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
if (typeof emitter.once !== 'function') {
|
||||
done()
|
||||
}
|
||||
}
|
||||
const finish = (error: Error | undefined, value?: T): void => {
|
||||
if (settled) {
|
||||
// Why: ssh2 can invoke the open callback after our timeout has
|
||||
@@ -196,6 +293,14 @@ export class SshConnection {
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
if (abortRequested) {
|
||||
if (!error && value !== undefined) {
|
||||
rejectAfterClose(value)
|
||||
} else {
|
||||
reject(createSshOperationAbortError())
|
||||
}
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
@@ -203,7 +308,9 @@ export class SshConnection {
|
||||
resolve(value as T)
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
onAbort()
|
||||
// No open is in flight yet, so failing fast leaks nothing.
|
||||
cleanup()
|
||||
reject(createSshOperationAbortError())
|
||||
return
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
@@ -286,8 +286,22 @@ describe('execCommand', () => {
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
|
||||
await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
// Why: sshd frees the MaxSessions slot only when the channel finishes
|
||||
// closing, so abort must request close and settle from the 'close' event —
|
||||
// settling immediately lets the sequential fallback race the closing
|
||||
// channel and get refused.
|
||||
expect(channel.close).toHaveBeenCalledOnce()
|
||||
const settledEarly = await Promise.race([
|
||||
commandPromise.then(
|
||||
() => 'settled',
|
||||
() => 'settled'
|
||||
),
|
||||
Promise.resolve('pending')
|
||||
])
|
||||
expect(settledEarly).toBe('pending')
|
||||
channel.emit('close', 0)
|
||||
|
||||
await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(channel.listenerCount('error')).toBe(0)
|
||||
expect(channel.listenerCount('data')).toBe(0)
|
||||
expect(channel.listenerCount('close')).toBe(0)
|
||||
@@ -313,8 +327,13 @@ describe('execCommand', () => {
|
||||
controller.abort()
|
||||
resolveExec(channel)
|
||||
|
||||
await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(channel.close).toHaveBeenCalledOnce()
|
||||
// Nonzero exit from the killed command must still surface as AbortError.
|
||||
channel.emit('close', 1)
|
||||
|
||||
await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(channel.listenerCount('error')).toBe(0)
|
||||
expect(channel.listenerCount('data')).toBe(0)
|
||||
expect(channel.listenerCount('close')).toBe(0)
|
||||
|
||||
@@ -275,11 +275,16 @@ export async function execCommand(
|
||||
fn(val as never)
|
||||
}
|
||||
const fail = (err: Error): void => {
|
||||
settle(reject, err)
|
||||
settle(reject, abortRequested ? createSshOperationAbortError() : err)
|
||||
}
|
||||
// Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE
|
||||
// completes. Settling on abort before the channel actually closes lets the
|
||||
// concurrent-bootstrap sequential fallback reissue an exec while the slot
|
||||
// is still held, so it gets refused again. Close and settle from onClose.
|
||||
let abortRequested = false
|
||||
const onAbort = (): void => {
|
||||
abortRequested = true
|
||||
channel.close()
|
||||
settle(reject, createSshOperationAbortError())
|
||||
}
|
||||
const onStdoutData = (data: Buffer): void => {
|
||||
stdout += data.toString('utf-8')
|
||||
@@ -288,7 +293,9 @@ export async function execCommand(
|
||||
stderr += data.toString('utf-8')
|
||||
}
|
||||
const onClose = (code: number): void => {
|
||||
if (code !== 0) {
|
||||
if (abortRequested) {
|
||||
settle(reject, createSshOperationAbortError())
|
||||
} else if (code !== 0) {
|
||||
// Why: on the system-ssh transport channel.stderr carries local OpenSSH
|
||||
// client noise; preferring it masks the real failure in stdout (2>&1).
|
||||
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n')
|
||||
@@ -299,7 +306,12 @@ export async function execCommand(
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
channel.close()
|
||||
settle(reject, new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`))
|
||||
settle(
|
||||
reject,
|
||||
abortRequested
|
||||
? createSshOperationAbortError()
|
||||
: new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)
|
||||
)
|
||||
}, timeoutMs)
|
||||
|
||||
// Why: remote reboot tears down exec channels with stream errors. Without
|
||||
|
||||
@@ -531,7 +531,19 @@ export class SshRelaySession {
|
||||
return false
|
||||
}
|
||||
|
||||
await this.installRemoteOrcaCliShim()
|
||||
try {
|
||||
await this.installRemoteOrcaCliShim()
|
||||
} catch (error) {
|
||||
// Why: the remote `orca` CLI shim is a convenience bridge. On session-
|
||||
// limited remotes (MaxSessions=1) the relay bridge holds the only slot,
|
||||
// so this raw-connection install can fail — that must not fail the
|
||||
// whole connection, matching the managed-hook install above.
|
||||
console.warn(
|
||||
`[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
if (shouldContinue && !shouldContinue()) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -331,6 +331,25 @@ describe('resolveRemoteNodePath', () => {
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => {
|
||||
// Why: in concurrent bootstrap a sibling probe aborts this resolution via
|
||||
// the shared signal. Each strategy catch swallows the injected AbortError,
|
||||
// so without re-raising it here the resolver would launder cancellation
|
||||
// into a fatal "Node.js not found" and defeat the sequential fallback.
|
||||
const controller = new AbortController()
|
||||
const abortError = Object.assign(new Error('SSH operation was cancelled'), {
|
||||
name: 'AbortError'
|
||||
})
|
||||
execCommandMock.mockImplementation(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(abortError)
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, undefined, { signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('throws when every candidate across both strategies is below the minimum', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/old/node\n') // path probe
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
|
||||
import { powerShellCommand } from './ssh-remote-powershell'
|
||||
@@ -47,7 +47,7 @@ export async function resolveRemoteNodePath(
|
||||
return loginShellPath
|
||||
}
|
||||
|
||||
throwNodeNotFound()
|
||||
throwNodeNotFound(options)
|
||||
}
|
||||
|
||||
// Probe the on-disk install directories of every common Node version manager
|
||||
@@ -245,10 +245,18 @@ async function resolveRemoteWindowsNodePath(
|
||||
// Fall through to the shared error below.
|
||||
}
|
||||
|
||||
throwNodeNotFound()
|
||||
throwNodeNotFound(options)
|
||||
}
|
||||
|
||||
function throwNodeNotFound(): never {
|
||||
function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never {
|
||||
// Why: when a sibling bootstrap probe aborts this resolution via the shared
|
||||
// signal, the strategy catch-blocks swallow the injected AbortError and fall
|
||||
// through to here. Surfacing "Node.js not found" would launder a cancellation
|
||||
// into a fatal non-session failure, defeating the concurrent-bootstrap
|
||||
// sequential fallback. Re-raise the abort so callers classify it correctly.
|
||||
if (options?.signal?.aborted) {
|
||||
throw createSshOperationAbortError()
|
||||
}
|
||||
throw new Error(
|
||||
'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
|
||||
'Install Node.js on the remote and try again.'
|
||||
|
||||
@@ -10,6 +10,16 @@ describe('isSshSessionLimitError', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the OpenSSH MaxSessions rejection (SSH2_OPEN_CONNECT_FAILED + "open failed")', () => {
|
||||
// Why: stock OpenSSH sshd refuses session channels over MaxSessions with
|
||||
// reason 2, not resource-shortage — observed against OpenSSH 9.2.
|
||||
expect(
|
||||
isSshSessionLimitError(
|
||||
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 })
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches OpenSSH mux and MaxSessions failures', () => {
|
||||
expect(
|
||||
isSshSessionLimitError(
|
||||
@@ -29,11 +39,6 @@ describe('isSshSessionLimitError', () => {
|
||||
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 1 })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSshSessionLimitError(
|
||||
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 2 })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSshSessionLimitError(
|
||||
Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 3 })
|
||||
|
||||
@@ -4,8 +4,11 @@ export function isSshSessionLimitError(err: unknown): boolean {
|
||||
}
|
||||
const reason = (err as { reason?: unknown }).reason
|
||||
const message = err.message.toLowerCase()
|
||||
// Why: OpenSSH rejects session channels over MaxSessions with
|
||||
// SSH2_OPEN_CONNECT_FAILED (2) and the literal description "open failed";
|
||||
// reason 4 (resource shortage) covers other server implementations.
|
||||
if (
|
||||
reason === 4 &&
|
||||
(reason === 2 || reason === 4) &&
|
||||
(message.includes('channel open failure') || message.includes('open failed'))
|
||||
) {
|
||||
return true
|
||||
|
||||
@@ -32,4 +32,25 @@ describe('desktop startup ordering', () => {
|
||||
expect(attachIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(startIndex).toBeGreaterThan(attachIndex)
|
||||
})
|
||||
|
||||
it('starts the automation scheduler before headless serve reports ready', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
const serveStart = source.indexOf('if (serveOptions) {')
|
||||
const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart)
|
||||
const serveReturn = source.indexOf('return', serveReady)
|
||||
const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart)
|
||||
const automationStart = source.indexOf('automations.start()', serveStart)
|
||||
const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)')
|
||||
const desktopAutomationStart = source.indexOf('automations.start()', desktopSetWebContents + 1)
|
||||
|
||||
expect(serveStart).toBeGreaterThanOrEqual(0)
|
||||
expect(serveReady).toBeGreaterThan(serveStart)
|
||||
expect(serveReturn).toBeGreaterThan(serveReady)
|
||||
expect(runtimeRpcStart).toBeGreaterThan(serveStart)
|
||||
expect(automationStart).toBeGreaterThan(runtimeRpcStart)
|
||||
expect(automationStart).toBeLessThan(serveReady)
|
||||
expect(automationStart).toBeLessThan(serveReturn)
|
||||
expect(desktopSetWebContents).toBeGreaterThanOrEqual(0)
|
||||
expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
getWindowShortcutActionId,
|
||||
matchesRecentTabSwitcherChord,
|
||||
nativeZoomCommandMatchesKeybindings,
|
||||
resolveWindowShortcutAction,
|
||||
windowShortcutActionCapturesTerminal,
|
||||
type WindowShortcutAction
|
||||
@@ -39,7 +40,6 @@ import {
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../shared/modifier-double-tap-detector'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
normalizeTerminalShortcutPolicy,
|
||||
type KeybindingMatchOptions,
|
||||
type KeybindingOverrides
|
||||
@@ -66,38 +66,6 @@ function forceRepaint(window: BrowserWindow): void {
|
||||
}, 32)
|
||||
}
|
||||
|
||||
function nativeZoomCommandMatchesKeybindings(
|
||||
direction: 'in' | 'out',
|
||||
platform: NodeJS.Platform,
|
||||
keybindings?: KeybindingOverrides,
|
||||
options: KeybindingMatchOptions = {}
|
||||
): boolean {
|
||||
const primary =
|
||||
platform === 'darwin' ? { meta: true, control: false } : { meta: false, control: true }
|
||||
const actionId = direction === 'in' ? 'zoom.in' : 'zoom.out'
|
||||
const candidates =
|
||||
direction === 'in'
|
||||
? [
|
||||
{ key: '=', code: 'Equal', shift: false },
|
||||
{ key: '+', code: 'Equal', shift: true },
|
||||
{ key: 'Add', code: 'NumpadAdd', shift: false }
|
||||
]
|
||||
: [
|
||||
{ key: '-', code: 'Minus', shift: false },
|
||||
{ key: 'Subtract', code: 'NumpadSubtract', shift: false }
|
||||
]
|
||||
|
||||
return candidates.some((candidate) =>
|
||||
keybindingMatchesAction(
|
||||
actionId,
|
||||
{ ...primary, alt: false, ...candidate },
|
||||
platform,
|
||||
keybindings,
|
||||
options
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isMacAppPasteInput(input: Electron.Input): boolean {
|
||||
return (
|
||||
process.platform === 'darwin' &&
|
||||
|
||||
@@ -878,19 +878,28 @@ function App(): React.JSX.Element {
|
||||
// active one) so a cold start that restored a remote workspace doesn't
|
||||
// hide local repos. The sidebar "All hosts" scope then shows them all.
|
||||
await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts())
|
||||
await timeRendererStartupStep('fetch-project-groups', () =>
|
||||
actions.fetchProjectGroupsForAllHosts()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-folder-workspaces', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts()
|
||||
)
|
||||
// Why: both of these fan out `git worktree list` per repo on the main
|
||||
// process. Running them concurrently lets the main process share one
|
||||
// Why: project-groups/folder-workspaces read neither the repos store nor
|
||||
// worktrees, so once repos land they can overlap the per-repo
|
||||
// `git worktree list` fan-out (#7225) instead of queuing ahead of it — a
|
||||
// slow remote host's 15s scope RPCs no longer block the scan. folders
|
||||
// still follow project-groups (they read projectGroups); all settle
|
||||
// before the hydrate steps below.
|
||||
const projectScopeChain = (async () => {
|
||||
await timeRendererStartupStep('fetch-project-groups', () =>
|
||||
actions.fetchProjectGroupsForAllHosts()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-folder-workspaces', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts()
|
||||
)
|
||||
})()
|
||||
// Why: worktrees + lineage both fan out `git worktree list` per repo on
|
||||
// the main process. Running them concurrently lets it share one
|
||||
// in-flight scan per repo instead of paying the process-spawn fan-out
|
||||
// twice back-to-back — the dominant renderer-chain cost on Windows
|
||||
// (issue #7225). Lineage only reads settings + its own slice, so it
|
||||
// does not depend on the worktrees fetch having landed.
|
||||
await Promise.all([
|
||||
projectScopeChain,
|
||||
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
|
||||
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
|
||||
])
|
||||
|
||||
@@ -6,9 +6,7 @@ describe('renderer startup runtime routing', () => {
|
||||
it('loads settings before repo and worktree hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
const startupBlockEnd = source.indexOf(
|
||||
"const persistedUI = await timeRendererStartupStep('ui-get'"
|
||||
)
|
||||
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
|
||||
@@ -17,6 +15,31 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()'))
|
||||
})
|
||||
|
||||
it('overlaps sidebar scope loads with worktree hydration before session hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()')
|
||||
const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {')
|
||||
const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()')
|
||||
const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()')
|
||||
const promiseAllIndex = startupBlock.indexOf('await Promise.all([')
|
||||
const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex)
|
||||
const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()')
|
||||
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
|
||||
|
||||
expect(reposIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(scopeChainIndex).toBeGreaterThan(reposIndex)
|
||||
expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex)
|
||||
expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex)
|
||||
expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex)
|
||||
expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(worktreesIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(lineageIndex).toBeGreaterThan(promiseAllIndex)
|
||||
})
|
||||
|
||||
it('waits for first-window startup services before terminal reconnect', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const reconnectIndex = source.indexOf('await actions.reconnectPersistedTerminals')
|
||||
|
||||
@@ -27,4 +27,14 @@ describe('AgentCombobox', () => {
|
||||
expect(markup).not.toContain('https://github.com/Gitlawb.png')
|
||||
expect(markup).not.toContain('<svg')
|
||||
})
|
||||
|
||||
it('uses the official OpenCode SVG mark instead of a remote favicon', () => {
|
||||
const markup = renderToStaticMarkup(<AgentIcon agent="opencode" />)
|
||||
|
||||
expect(markup).toContain('<svg')
|
||||
expect(markup).toContain('viewBox="0 0 512 512"')
|
||||
expect(markup).not.toContain('/resources/opencode.webp')
|
||||
expect(markup).not.toContain('https://www.google.com/s2/favicons')
|
||||
expect(markup).not.toContain('<img')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
|
||||
|
||||
const ANDROID_COPY: InstallCopy = {
|
||||
ctaLabel: 'Download APK',
|
||||
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
|
||||
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk'
|
||||
}
|
||||
|
||||
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { i18n } from '../../i18n/i18n'
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
retainedAgentsByPaneKey: {},
|
||||
petSize: 180
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
<T,>(selector: (state: typeof storeState) => T): T => {
|
||||
return selector(storeState)
|
||||
},
|
||||
{
|
||||
getState: () => storeState
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('./usePetUrl', () => ({
|
||||
usePetUrl: () => ({
|
||||
url: 'blob:custom-pet',
|
||||
ready: true,
|
||||
sprite: {
|
||||
frameWidth: 32,
|
||||
frameHeight: 24,
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
sheetWidth: 192,
|
||||
sheetHeight: 24,
|
||||
fps: 6,
|
||||
defaultAnimation: 'idle',
|
||||
animations: {
|
||||
idle: { row: 0, frames: 6 }
|
||||
}
|
||||
},
|
||||
detected: null
|
||||
})
|
||||
}))
|
||||
|
||||
import { PetOverlay } from './PetOverlay'
|
||||
|
||||
function renderPetOverlay(): { container: HTMLDivElement; root: Root } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(<PetOverlay />)
|
||||
})
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
function installLocalStorage(): void {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
setItem: (key: string, value: string) => values.set(key, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('PetOverlay sprite keyframes', () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('zh')
|
||||
installLocalStorage()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it('keeps custom spritesheet keyframes valid in translated locales', () => {
|
||||
;({ container, root } = renderPetOverlay())
|
||||
|
||||
const css = Array.from(container.querySelectorAll('style'))
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n')
|
||||
|
||||
expect(css).toContain('@keyframes pet-bob')
|
||||
expect(css).toContain('transform: translateY(0)')
|
||||
expect(css).toContain('background-position:')
|
||||
expect(css).toContain('to { background-position:')
|
||||
expect(css).not.toContain('变换')
|
||||
expect(css).not.toContain('背景位置')
|
||||
expect(css).not.toContain('到 {')
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ import type { CustomPet } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
|
||||
import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Sprite = NonNullable<CustomPet['sprite']>
|
||||
|
||||
@@ -66,15 +65,12 @@ function SpriteFrame({
|
||||
const startY = -(row * sprite.frameHeight * scale)
|
||||
const endX = -(frames * sprite.frameWidth * scale)
|
||||
const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps))
|
||||
// Why: sprite keyframes are runtime CSS, not user-visible copy; translated
|
||||
// CSS keywords make the browser discard the animation.
|
||||
const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }`
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{translate(
|
||||
'auto.components.pet.PetOverlay.4712d196c6',
|
||||
'@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }',
|
||||
{ value0: animKeyframesId, value1: startX, value2: startY, value3: endX, value4: startY }
|
||||
)}
|
||||
</style>
|
||||
<style>{keyframesCss}</style>
|
||||
<div
|
||||
style={{
|
||||
width: renderedW,
|
||||
@@ -285,6 +281,10 @@ function defaultPosition(size: number = SIZE): Position {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the bob float is runtime CSS, not user-visible copy; keep CSS keywords
|
||||
// out of i18n so translated locales cannot invalidate the keyframes.
|
||||
const PET_BOB_KEYFRAMES_CSS =
|
||||
'@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }'
|
||||
export function PetOverlay(): React.JSX.Element {
|
||||
const documentVisible = useDocumentVisible()
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
@@ -417,12 +417,7 @@ export function PetOverlay(): React.JSX.Element {
|
||||
minHeight: 24
|
||||
}}
|
||||
>
|
||||
<style>
|
||||
{translate(
|
||||
'auto.components.pet.PetOverlay.de932b0e8f',
|
||||
'@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }'
|
||||
)}
|
||||
</style>
|
||||
<style>{PET_BOB_KEYFRAMES_CSS}</style>
|
||||
{sprite ? (
|
||||
<SpriteFrame
|
||||
url={url}
|
||||
|
||||
@@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => {
|
||||
openBranchDiff: vi.fn(),
|
||||
createEmptySplitGroup: vi.fn(),
|
||||
discardRuntimeGitPath: vi.fn(),
|
||||
bulkStageRuntimeGitPaths: vi.fn(),
|
||||
refreshGitStatusForWorktree: vi.fn(),
|
||||
requestEditorSaveQuiesce: vi.fn(),
|
||||
notifyEditorExternalFileChange: vi.fn()
|
||||
@@ -85,7 +86,8 @@ vi.mock('@/runtime/runtime-git-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath
|
||||
discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath,
|
||||
bulkStageRuntimeGitPaths: mocks.calls.bulkStageRuntimeGitPaths
|
||||
}
|
||||
})
|
||||
|
||||
@@ -140,6 +142,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
vi.clearAllMocks()
|
||||
mocks.calls.createEmptySplitGroup.mockReturnValue('group-2')
|
||||
mocks.calls.discardRuntimeGitPath.mockResolvedValue(undefined)
|
||||
mocks.calls.bulkStageRuntimeGitPaths.mockResolvedValue(undefined)
|
||||
mocks.calls.refreshGitStatusForWorktree.mockResolvedValue(undefined)
|
||||
mocks.calls.requestEditorSaveQuiesce.mockResolvedValue(undefined)
|
||||
mocks.state = {
|
||||
@@ -437,13 +440,76 @@ describe('SourceControl preview row opens', () => {
|
||||
const row = container.querySelector<HTMLDivElement>(
|
||||
'[data-source-control-path="packages/nested"]'
|
||||
)
|
||||
expect(row?.textContent).toContain('Submodule changes - stage inside submodule')
|
||||
expect(row?.textContent).toContain('Stage inside submodule')
|
||||
expect(
|
||||
row?.querySelector('[title*="cannot stage file changes inside a submodule"]')
|
||||
).not.toBeNull()
|
||||
|
||||
const stageButton = row?.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Stage these changes inside the submodule"]'
|
||||
expect(row?.querySelector<HTMLButtonElement>('button[aria-label="Stage"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render a commit-area Stage All primary for submodule worktree-only rows', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: [
|
||||
gitEntry({
|
||||
path: 'june-11th-launch',
|
||||
submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
const row = container.querySelector<HTMLDivElement>(
|
||||
'[data-source-control-path="june-11th-launch"]'
|
||||
)
|
||||
expect(row?.textContent).toContain('Stage inside submodule')
|
||||
|
||||
const stageAllButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Stage All'
|
||||
)
|
||||
expect(stageAllButton).toBeUndefined()
|
||||
|
||||
const commitButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Commit'
|
||||
)
|
||||
expect(commitButton).toBeDefined()
|
||||
expect(commitButton?.disabled).toBe(true)
|
||||
|
||||
act(() => {
|
||||
commitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(mocks.calls.bulkStageRuntimeGitPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the commit-area Stage All primary for changed submodule gitlinks', async () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: [
|
||||
gitEntry({
|
||||
path: 'packages/nested',
|
||||
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
const stageAllButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Stage All'
|
||||
)
|
||||
expect(stageAllButton).toBeDefined()
|
||||
expect(stageAllButton?.disabled).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
stageAllButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(mocks.calls.bulkStageRuntimeGitPaths).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: mocks.activeWorktree.id, worktreePath: '/repo/wt' }),
|
||||
['packages/nested']
|
||||
)
|
||||
expect(stageButton).not.toBeNull()
|
||||
expect(stageButton?.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('passes preview=true when a plain branch row click opens a branch diff tab', () => {
|
||||
|
||||
@@ -536,8 +536,9 @@ const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8
|
||||
const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20
|
||||
const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' }
|
||||
const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const
|
||||
const SUBMODULE_WORKTREE_ONLY_LABEL = 'Submodule changes - stage inside submodule'
|
||||
const SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP = 'Stage these changes inside the submodule'
|
||||
const SUBMODULE_WORKTREE_ONLY_LABEL = 'Stage inside submodule'
|
||||
const SUBMODULE_WORKTREE_ONLY_TOOLTIP =
|
||||
'The parent repo (including Stage All) cannot stage file changes inside a submodule'
|
||||
const SUBMODULE_LOADING_LABEL = 'Loading submodule changes…'
|
||||
const SUBMODULE_EMPTY_LABEL = 'No changes in submodule'
|
||||
const SUBMODULE_ERROR_LABEL = 'Failed to load submodule changes'
|
||||
@@ -3995,7 +3996,12 @@ function SourceControlInner(): React.JSX.Element {
|
||||
])
|
||||
|
||||
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
|
||||
const hasStageableChanges = hasUnstagedChanges
|
||||
const hasStageableChanges = useMemo(
|
||||
() =>
|
||||
grouped.unstaged.some(isStageableStatusEntry) ||
|
||||
grouped.untracked.some(isStageableStatusEntry),
|
||||
[grouped.unstaged, grouped.untracked]
|
||||
)
|
||||
const hasPartiallyStagedChanges = useMemo(() => {
|
||||
if (grouped.staged.length === 0 || grouped.unstaged.length === 0) {
|
||||
return false
|
||||
@@ -8115,9 +8121,17 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>
|
||||
)}
|
||||
</span>
|
||||
{(conflictLabel || isSubmoduleWorktreeOnly) && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{conflictLabel ?? SUBMODULE_WORKTREE_ONLY_LABEL}
|
||||
{conflictLabel && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">{conflictLabel}</div>
|
||||
)}
|
||||
{isSubmoduleWorktreeOnly && (
|
||||
// Why: parent git can stage a changed gitlink, but not nested
|
||||
// worktree dirtiness. Keep that boundary visible in the row.
|
||||
<div
|
||||
className="truncate text-[11px] text-muted-foreground"
|
||||
title={SUBMODULE_WORKTREE_ONLY_TOOLTIP}
|
||||
>
|
||||
{SUBMODULE_WORKTREE_ONLY_LABEL}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -8176,19 +8190,14 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(canStage || isSubmoduleWorktreeOnly) && (
|
||||
{canStage && (
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
title={
|
||||
isSubmoduleWorktreeOnly
|
||||
? SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP
|
||||
: translate('auto.components.right.sidebar.SourceControl.8cde1a2fb0', 'Stage')
|
||||
}
|
||||
title={translate('auto.components.right.sidebar.SourceControl.8cde1a2fb0', 'Stage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void onStage(entry.path)
|
||||
}}
|
||||
disabled={isSubmoduleWorktreeOnly}
|
||||
/>
|
||||
)}
|
||||
{canUnstage && (
|
||||
|
||||
@@ -1609,13 +1609,6 @@ function PRCommentActionBadge({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (actionState === 'open') {
|
||||
return (
|
||||
<span className={presentation.statusBadgeOpen}>
|
||||
{translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (actionState === 'resolved') {
|
||||
return (
|
||||
<span className={presentation.statusBadgeResolved}>
|
||||
|
||||
@@ -41,7 +41,6 @@ export type PRCommentPresentationClasses = {
|
||||
audienceTab: string
|
||||
audienceTabActive: string
|
||||
sectionTriageLabel: string
|
||||
statusBadgeOpen: string
|
||||
statusBadgeResolved: string
|
||||
statusBadgeQueued: string
|
||||
commentHeaderPrimary: string
|
||||
@@ -160,8 +159,6 @@ export function getPRCommentPresentationClasses(
|
||||
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
|
||||
audienceTabActive: 'bg-muted text-foreground',
|
||||
sectionTriageLabel: cn('px-3 pt-2', RESOLVED_SECTION_LABEL),
|
||||
statusBadgeOpen:
|
||||
'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
|
||||
statusBadgeResolved:
|
||||
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
|
||||
statusBadgeQueued:
|
||||
@@ -211,8 +208,6 @@ export function getPRCommentPresentationClasses(
|
||||
'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors',
|
||||
audienceTabActive: 'bg-muted text-foreground shadow-xs',
|
||||
sectionTriageLabel: cn('px-3 pt-1', RESOLVED_SECTION_LABEL),
|
||||
statusBadgeOpen:
|
||||
'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success',
|
||||
statusBadgeResolved:
|
||||
'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground',
|
||||
statusBadgeQueued:
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('AccountsPane', () => {
|
||||
it('keeps the runtime label inside the localized account copy', () => {
|
||||
const markup = renderPane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(markup).toContain('Showing accounts for This device. New accounts are added there.')
|
||||
expect(markup).toContain('authenticate with Google for This device. This uses credentials')
|
||||
expect(markup).toContain('Showing accounts for this device. New accounts are added there.')
|
||||
expect(markup).toContain('authenticate with Google for this device. This uses credentials')
|
||||
expect(markup).not.toContain('ShowingThis device')
|
||||
expect(markup).not.toContain('forThis device')
|
||||
})
|
||||
@@ -65,8 +65,8 @@ describe('AccountsPane', () => {
|
||||
|
||||
const markup = renderPane(getDefaultSettings('/tmp'))
|
||||
|
||||
expect(markup).toContain(
|
||||
'Mostrando cuentas para este dispositivo. Las nuevas cuentas se agregan allí.'
|
||||
expect(markup).toMatch(
|
||||
/Mostrando cuentas para [Ee]ste dispositivo\. Las nuevas cuentas se agregan allí\./
|
||||
)
|
||||
expect(markup).not.toContain('This device')
|
||||
})
|
||||
|
||||
@@ -264,6 +264,11 @@ export function AccountsPane({
|
||||
wslDistros,
|
||||
wslCapabilitiesLoading
|
||||
)
|
||||
// Why: host runtime labels are standalone UI labels; interpolated prose needs sentence casing.
|
||||
const accountRuntimeSentenceLabel =
|
||||
accountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows')
|
||||
? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}`
|
||||
: accountRuntime.label
|
||||
|
||||
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
@@ -611,7 +616,7 @@ export function AccountsPane({
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.c0a52abfc5',
|
||||
'Showing accounts for {{value0}}. New accounts are added there.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -693,7 +698,7 @@ export function AccountsPane({
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.e05d0ff737',
|
||||
'Use your current {{value0}} Claude login.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -703,7 +708,7 @@ export function AccountsPane({
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.3fe7862418',
|
||||
"No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.",
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -863,7 +868,7 @@ export function AccountsPane({
|
||||
: translate(
|
||||
'auto.components.settings.AccountsPane.e4a28e8894',
|
||||
'Codex reported that the {{value0}} login needs a fresh sign-in. Sign in again before starting new Codex sessions.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -877,7 +882,7 @@ export function AccountsPane({
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.c0a52abfc5',
|
||||
'Showing accounts for {{value0}}. New accounts are added there.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -964,12 +969,12 @@ export function AccountsPane({
|
||||
? translate(
|
||||
'auto.components.settings.AccountsPane.fd62f37c24',
|
||||
'Codex reported this {{value0}} login is out of date.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.AccountsPane.fcc4093fc1',
|
||||
'Use your current {{value0}} Codex login.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -979,7 +984,7 @@ export function AccountsPane({
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.b4c9450319',
|
||||
"No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.",
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -1177,7 +1182,7 @@ export function AccountsPane({
|
||||
{translate(
|
||||
'auto.components.settings.AccountsPane.c2aee76420',
|
||||
'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.',
|
||||
{ value0: accountRuntime.label }
|
||||
{ value0: accountRuntimeSentenceLabel }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -174,8 +174,11 @@ export function BrowserProfileRow({
|
||||
browser.profiles.length > 1 ? (
|
||||
<DropdownMenuSub key={browser.family}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
|
||||
{browser.label}
|
||||
{translate(
|
||||
'auto.components.settings.BrowserProfileRow.c5a273a809',
|
||||
'From {{value0}}',
|
||||
{ value0: browser.label }
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
@@ -197,8 +200,11 @@ export function BrowserProfileRow({
|
||||
key={browser.family}
|
||||
onSelect={() => void handleImportFromBrowser(browser.family)}
|
||||
>
|
||||
{translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')}
|
||||
{browser.label}
|
||||
{translate(
|
||||
'auto.components.settings.BrowserProfileRow.c5a273a809',
|
||||
'From {{value0}}',
|
||||
{ value0: browser.label }
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -116,9 +116,9 @@ export function BrowserUseCookieImportStep({
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.BrowserUsePane.112f70adc4',
|
||||
'Last imported from'
|
||||
'Last imported from {{value0}}',
|
||||
{ value0: sourceLabel }
|
||||
)}
|
||||
{sourceLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{onConfigureMoreBrowsers ? (
|
||||
@@ -163,8 +163,11 @@ export function BrowserUseCookieImportStep({
|
||||
browser.profiles.length > 1 ? (
|
||||
<DropdownMenuSub key={browser.family}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
|
||||
{browser.label}
|
||||
{translate(
|
||||
'auto.components.settings.BrowserUsePane.5301857d88',
|
||||
'From {{value0}}',
|
||||
{ value0: browser.label }
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
@@ -186,8 +189,11 @@ export function BrowserUseCookieImportStep({
|
||||
key={browser.family}
|
||||
onSelect={() => void handleImportFromBrowser(browser.family)}
|
||||
>
|
||||
{translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')}
|
||||
{browser.label}
|
||||
{translate(
|
||||
'auto.components.settings.BrowserUsePane.5301857d88',
|
||||
'From {{value0}}',
|
||||
{ value0: browser.label }
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -99,6 +99,25 @@ describe('GhosttyImportModal', () => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('labels Ghostty line-height imports with the settings display name', () => {
|
||||
const element = GhosttyImportModal({
|
||||
open: true,
|
||||
onOpenChange: () => {},
|
||||
preview: {
|
||||
found: true,
|
||||
configPath: '/Users/alice/.config/ghostty/config',
|
||||
diff: { terminalLineHeight: 1.35 },
|
||||
unsupportedKeys: []
|
||||
},
|
||||
loading: false,
|
||||
onApply: () => {},
|
||||
applied: false
|
||||
})
|
||||
|
||||
expect(containsText(element, 'Line Height')).toBe(true)
|
||||
expect(containsText(element, 'terminalLineHeight')).toBe(false)
|
||||
})
|
||||
|
||||
it('renders success summary with done button when applied', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onApply = vi.fn()
|
||||
|
||||
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
|
||||
|
||||
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
|
||||
const ORCA_ANDROID_APK_URL =
|
||||
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
|
||||
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk'
|
||||
|
||||
export function MobileSettingsPane(): React.JSX.Element {
|
||||
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import en from '@/i18n/locales/en.json'
|
||||
import { i18n, translate } from '@/i18n/i18n'
|
||||
|
||||
// Keys for the "From <browser>" import-cookies menu entries. The label is built
|
||||
// by interpolating the browser name into a single localized string; a regression
|
||||
// where the name was concatenated directly onto "From" dropped the space and
|
||||
// rendered e.g. "FromGoogle Chrome".
|
||||
const IMPORT_FROM_KEYS = [
|
||||
'auto.components.settings.BrowserProfileRow.c5a273a809',
|
||||
'auto.components.settings.BrowserUsePane.5301857d88'
|
||||
]
|
||||
const LAST_IMPORTED_FROM_KEY = 'auto.components.settings.BrowserUsePane.112f70adc4'
|
||||
|
||||
function catalogEntry(key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((node, part) => {
|
||||
if (typeof node === 'object' && node !== null) {
|
||||
return (node as Record<string, unknown>)[part]
|
||||
}
|
||||
return undefined
|
||||
}, en)
|
||||
}
|
||||
|
||||
describe('import-cookies "From <browser>" label', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it('keeps a space between "From" and the browser name', () => {
|
||||
for (const key of IMPORT_FROM_KEYS) {
|
||||
expect(translate(key, 'From {{value0}}', { value0: 'Google Chrome' })).toBe(
|
||||
'From Google Chrome'
|
||||
)
|
||||
expect(translate(key, 'From {{value0}}', { value0: 'Safari' })).toBe('From Safari')
|
||||
}
|
||||
})
|
||||
|
||||
it('stores the catalog entry with an interpolation placeholder', () => {
|
||||
for (const key of IMPORT_FROM_KEYS) {
|
||||
expect(catalogEntry(key)).toBe('From {{value0}}')
|
||||
}
|
||||
expect(catalogEntry(LAST_IMPORTED_FROM_KEY)).toBe('Last imported from {{value0}}')
|
||||
})
|
||||
|
||||
it('keeps a space in the last-imported source label', () => {
|
||||
expect(
|
||||
translate(LAST_IMPORTED_FROM_KEY, 'Last imported from {{value0}}', {
|
||||
value0: 'Google Chrome'
|
||||
})
|
||||
).toBe('Last imported from Google Chrome')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ export const SETTING_LABELS: Partial<Record<keyof GlobalSettings, string>> = {
|
||||
terminalFontSize: 'Font Size',
|
||||
terminalFontFamily: 'Font Family',
|
||||
terminalFontWeight: 'Font Weight',
|
||||
terminalLineHeight: 'Line Height',
|
||||
terminalScrollSensitivity: 'Normal Scroll Speed',
|
||||
terminalFastScrollSensitivity: 'Fast Scroll Speed',
|
||||
terminalTuiScrollSensitivity: 'TUI Scroll Speed',
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
||||
worktreeCardProperties = ['status']
|
||||
})
|
||||
|
||||
it('opens the reconnect dialog for an active disconnected SSH worktree during render', () => {
|
||||
it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => {
|
||||
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
|
||||
sshTargetLabels.set('ssh-target-1', 'Remote target')
|
||||
|
||||
@@ -141,9 +141,11 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
||||
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={true} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-ssh-disconnected-dialog="open"')
|
||||
expect(markup).toContain('data-ssh-status="disconnected"')
|
||||
expect(markup).toContain('data-ssh-target-label="Remote target"')
|
||||
// The dialog is blocking, so being the active/restored card must not steal
|
||||
// focus app-wide; it only opens on deliberate click (see handleClick).
|
||||
expect(markup).toContain('data-ssh-disconnected-dialog="closed"')
|
||||
// The disconnected state is still discoverable via the non-blocking card chip.
|
||||
expect(markup).toContain('SSH disconnected')
|
||||
})
|
||||
|
||||
it('marks a runtime-host worktree disconnected when its environment has no status', () => {
|
||||
|
||||
@@ -342,6 +342,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
return state?.status ?? 'disconnected'
|
||||
})
|
||||
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
|
||||
// Why: a terminal view already carries its own in-pane reconnect overlay, so
|
||||
// the blocking dialog would just duplicate it there; reserve the dialog for
|
||||
// views without an in-context prompt. Default to terminal (suppress) for the
|
||||
// ambiguous case so we err toward non-blocking.
|
||||
const activeViewIsTerminal = useAppStore(
|
||||
(s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal'
|
||||
)
|
||||
|
||||
// Why: runtime ("Orca server") hosts get the same disconnected treatment as
|
||||
// SSH — when the host's runtime environment has no live status, its worktrees
|
||||
@@ -353,23 +360,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
}
|
||||
return !s.runtimeStatusByEnvironmentId.get(parsed.environmentId)?.status
|
||||
})
|
||||
// Why: the reconnect dialog is blocking, so it is never auto-shown for a
|
||||
// disconnected worktree just because it is the active/restored card — that
|
||||
// would steal focus app-wide while the user works elsewhere. The card chip,
|
||||
// status bar, and terminal overlay carry the non-blocking disconnected state;
|
||||
// the dialog only opens on deliberate focus (see handleClick).
|
||||
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
|
||||
const sshDisconnectedPromptKey = isActive && isSshDisconnected ? worktree.id : null
|
||||
const [lastSshDisconnectedPromptKey, setLastSshDisconnectedPromptKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [titleRenaming, setTitleRenaming] = useState(false)
|
||||
const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false)
|
||||
|
||||
// Why: on restart the previously-active worktree is auto-restored without a
|
||||
// click, so the dialog never opens. Auto-show it for the active card when SSH
|
||||
// is disconnected, but keep dismissals sticky until that prompt key changes.
|
||||
if (sshDisconnectedPromptKey !== lastSshDisconnectedPromptKey) {
|
||||
setLastSshDisconnectedPromptKey(sshDisconnectedPromptKey)
|
||||
if (sshDisconnectedPromptKey) {
|
||||
setShowDisconnectedDialog(true)
|
||||
}
|
||||
}
|
||||
// Why: read the target label from the store (populated during hydration in
|
||||
// useIpcEvents.ts) instead of calling listTargets IPC per card instance.
|
||||
const sshTargetLabel = useAppStore((s) =>
|
||||
@@ -862,7 +860,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
})
|
||||
onImmediateActivate?.(worktree.id, activationRowKey)
|
||||
void activateWorktreeFromSidebar(worktree.id)
|
||||
if (isSshDisconnected) {
|
||||
// Why: clicking the card is a deliberate focus of this project, so the
|
||||
// blocking reconnect prompt is appropriate here (unlike auto-restore) —
|
||||
// but skip it when a terminal is active, since that pane already shows the
|
||||
// in-context reconnect overlay and a second prompt would just duplicate it.
|
||||
if (isSshDisconnected && !activeViewIsTerminal) {
|
||||
setShowDisconnectedDialog(true)
|
||||
}
|
||||
onActivate?.()
|
||||
@@ -875,6 +877,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
isDeleting,
|
||||
activationRowKey,
|
||||
isSshDisconnected,
|
||||
activeViewIsTerminal,
|
||||
onActivate,
|
||||
onImmediateActivate,
|
||||
onSelectionGesture
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
|
||||
import { buildWorktreeMetaUpdates, type WorktreeMetaSavedPayload } from './worktree-meta-updates'
|
||||
import {
|
||||
buildWorktreeMetaUpdates,
|
||||
parseGitHubWorkItemNumberForMetaField,
|
||||
type WorktreeMetaSavedPayload
|
||||
} from './worktree-meta-updates'
|
||||
import { useWorktreeIssueLink } from './use-worktree-issue-link'
|
||||
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
import { ExternalLink, LoaderCircle } from 'lucide-react'
|
||||
@@ -95,8 +98,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
|
||||
}
|
||||
const trimmedIssue = issueInput.trim()
|
||||
const trimmedPR = prInput.trim()
|
||||
const issueValid = trimmedIssue === '' || parseGitHubIssueOrPRNumber(trimmedIssue) !== null
|
||||
const prValid = trimmedPR === '' || parseGitHubIssueOrPRNumber(trimmedPR) !== null
|
||||
const issueValid =
|
||||
trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null
|
||||
const prValid =
|
||||
trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null
|
||||
return issueValid && prValid
|
||||
}, [worktreeId, issueInput, prInput])
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorktreeMetaUpdates } from './worktree-meta-updates'
|
||||
|
||||
describe('buildWorktreeMetaUpdates', () => {
|
||||
it('rejects issue URLs in the PR input', () => {
|
||||
expect(
|
||||
buildWorktreeMetaUpdates({
|
||||
displayNameInput: 'Workspace',
|
||||
currentDisplayName: 'Workspace',
|
||||
issueInput: '',
|
||||
prInput: 'https://github.com/stablyai/orca/issues/6933',
|
||||
commentInput: ''
|
||||
})
|
||||
).toEqual({
|
||||
comment: '',
|
||||
linkedIssue: null
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts PR URLs in the PR input', () => {
|
||||
expect(
|
||||
buildWorktreeMetaUpdates({
|
||||
displayNameInput: 'Workspace',
|
||||
currentDisplayName: 'Workspace',
|
||||
issueInput: '',
|
||||
prInput: 'https://github.com/stablyai/orca/pull/6934',
|
||||
commentInput: ''
|
||||
})
|
||||
).toEqual({
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: 6934
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts issue URLs in the issue input', () => {
|
||||
expect(
|
||||
buildWorktreeMetaUpdates({
|
||||
displayNameInput: 'Workspace',
|
||||
currentDisplayName: 'Workspace',
|
||||
issueInput: 'https://github.com/stablyai/orca/issues/6933',
|
||||
prInput: '',
|
||||
commentInput: ''
|
||||
})
|
||||
).toEqual({
|
||||
comment: '',
|
||||
linkedIssue: 6933,
|
||||
linkedPR: null
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects PR URLs in the issue input', () => {
|
||||
expect(
|
||||
buildWorktreeMetaUpdates({
|
||||
displayNameInput: 'Workspace',
|
||||
currentDisplayName: 'Workspace',
|
||||
issueInput: 'https://github.com/stablyai/orca/pull/6934',
|
||||
prInput: '',
|
||||
commentInput: ''
|
||||
})
|
||||
).toEqual({
|
||||
comment: '',
|
||||
linkedPR: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,20 @@ export function parseExplicitGitHubIssueUrl(input: string): string | null {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function parseGitHubWorkItemNumberForMetaField(
|
||||
input: string,
|
||||
expectedType: 'issue' | 'pr'
|
||||
): number | null {
|
||||
const link = parseGitHubIssueOrPRLink(input)
|
||||
if (link) {
|
||||
// Why: issue and PR numbers live in separate GitHub namespaces for refs;
|
||||
// a URL path mismatch must not silently link the other field.
|
||||
return link.type === expectedType ? link.number : null
|
||||
}
|
||||
|
||||
return parseGitHubIssueOrPRNumber(input)
|
||||
}
|
||||
|
||||
/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
|
||||
* the link (null), unparseable inputs leave it untouched (omitted). */
|
||||
export function buildWorktreeMetaUpdates(args: {
|
||||
@@ -26,11 +40,11 @@ export function buildWorktreeMetaUpdates(args: {
|
||||
commentInput: string
|
||||
}): Partial<WorktreeMeta> {
|
||||
const trimmedIssue = args.issueInput.trim()
|
||||
const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue)
|
||||
const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue')
|
||||
const finalLinkedIssue =
|
||||
trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
|
||||
const trimmedPR = args.prInput.trim()
|
||||
const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR)
|
||||
const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')
|
||||
const finalLinkedPR =
|
||||
trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
|
||||
|
||||
|
||||
@@ -116,12 +116,8 @@ describe('resource manager row presentation', () => {
|
||||
expect(container.textContent).toContain('· remote')
|
||||
const killButtons = container.querySelectorAll('button[aria-label^="Kill session"]')
|
||||
expect(killButtons).toHaveLength(2)
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Kill session ssh-a"]')
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Kill session ssh-b"]')
|
||||
).not.toBeNull()
|
||||
expect(container.querySelector('button[aria-label="Kill session ssh-a"]')).not.toBeNull()
|
||||
expect(container.querySelector('button[aria-label="Kill session ssh-b"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps kill affordances on the orphan bucket rows', () => {
|
||||
@@ -133,8 +129,6 @@ describe('resource manager row presentation', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Kill session orphan-a"]')
|
||||
).not.toBeNull()
|
||||
expect(container.querySelector('button[aria-label="Kill session orphan-a"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,8 +57,6 @@ function renderMenu(overrides: Record<string, unknown> = {}): void {
|
||||
canClosePane: true,
|
||||
canExpandPane: true,
|
||||
menuPaneIsExpanded: false,
|
||||
linkUrl: null,
|
||||
onOpenLinkInDefaultBrowser: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onPaste: vi.fn(),
|
||||
onSplitRight: vi.fn(),
|
||||
@@ -116,24 +114,6 @@ describe('TerminalContextMenu', () => {
|
||||
expect(onForkAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Open in Default Browser" only when a link is under the cursor', () => {
|
||||
renderMenu({ linkUrl: null })
|
||||
expect(
|
||||
items.list.some((item) => childrenText(item.children) === 'Open in Default Browser')
|
||||
).toBe(false)
|
||||
|
||||
items.list = []
|
||||
const onOpenLinkInDefaultBrowser = vi.fn()
|
||||
renderMenu({ linkUrl: 'https://example.com/docs', onOpenLinkInDefaultBrowser })
|
||||
|
||||
const openLinkItem = items.list.find(
|
||||
(item) => childrenText(item.children) === 'Open in Default Browser'
|
||||
)
|
||||
expect(openLinkItem).toBeDefined()
|
||||
openLinkItem?.onSelect?.()
|
||||
expect(onOpenLinkInDefaultBrowser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows one shortcut per terminal menu action on Windows', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Eraser,
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Maximize2,
|
||||
MessageSquare,
|
||||
@@ -32,11 +31,12 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
|
||||
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import type { KeybindingOverrides } from '../../../../shared/keybindings'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut'
|
||||
import { TerminalQuickCommandMenuItem } from './TerminalQuickCommandMenuItem'
|
||||
|
||||
type TerminalContextMenuProps = {
|
||||
open: boolean
|
||||
@@ -46,8 +46,6 @@ type TerminalContextMenuProps = {
|
||||
canClosePane: boolean
|
||||
canExpandPane: boolean
|
||||
menuPaneIsExpanded: boolean
|
||||
linkUrl: string | null
|
||||
onOpenLinkInDefaultBrowser: () => void
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
onSplitRight: () => void
|
||||
@@ -83,8 +81,6 @@ export default function TerminalContextMenu({
|
||||
canClosePane,
|
||||
canExpandPane,
|
||||
menuPaneIsExpanded,
|
||||
linkUrl,
|
||||
onOpenLinkInDefaultBrowser,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSplitRight,
|
||||
@@ -132,6 +128,27 @@ export default function TerminalContextMenu({
|
||||
const showEqualizeShortcut = shortcuts.equalize !== 'Unassigned'
|
||||
const showSetTitleShortcut = shortcuts.setTitle !== 'Unassigned'
|
||||
const showClearPaneTitleShortcut = shortcuts.clearPaneTitle !== 'Unassigned'
|
||||
const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
|
||||
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
|
||||
{isTerminalAgentQuickCommand(command) ? (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<AgentIcon agent={command.agent} size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<Play
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{command.label}</span>
|
||||
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
|
||||
</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
@@ -177,18 +194,6 @@ export default function TerminalContextMenu({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkUrl ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onOpenLinkInDefaultBrowser}>
|
||||
<ExternalLink />
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalContextMenu.openLinkDefaultBrowser',
|
||||
'Open in Default Browser'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={onCopy}>
|
||||
<Copy />
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.f3eeb1de13', 'Copy')}
|
||||
@@ -215,13 +220,7 @@ export default function TerminalContextMenu({
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{quickCommandRepoLabel}
|
||||
</DropdownMenuLabel>
|
||||
{repoQuickCommands.map((command) => (
|
||||
<TerminalQuickCommandMenuItem
|
||||
key={command.id}
|
||||
command={command}
|
||||
onSelect={onQuickCommand}
|
||||
/>
|
||||
))}
|
||||
{repoQuickCommands.map(renderQuickCommandItem)}
|
||||
</>
|
||||
) : null}
|
||||
{globalQuickCommands.length > 0 ? (
|
||||
@@ -235,13 +234,7 @@ export default function TerminalContextMenu({
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
) : null}
|
||||
{globalQuickCommands.map((command) => (
|
||||
<TerminalQuickCommandMenuItem
|
||||
key={command.id}
|
||||
command={command}
|
||||
onSelect={onQuickCommand}
|
||||
/>
|
||||
))}
|
||||
{globalQuickCommands.map(renderQuickCommandItem)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -93,6 +93,7 @@ import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
|
||||
import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
|
||||
import {
|
||||
inspectRuntimeTerminalProcess,
|
||||
isRemoteRuntimePtyId
|
||||
@@ -350,8 +351,9 @@ export default function TerminalPane({
|
||||
const daemonActions = useDaemonActions()
|
||||
// Why: override state lives in a plain Map for perf (safeFit reads it on
|
||||
// every resize). This counter forces a re-render when overrides change so
|
||||
// the mobile-fit banner appears/disappears. When an override is cleared
|
||||
// (desktop-fit), we also trigger safeFit on affected panes so the terminal
|
||||
// the mobile-fit banner appears/disappears. On both transitions we also
|
||||
// trigger safeFit on affected panes: mobile-fit shrinks the watcher's xterm
|
||||
// to phone dims (matching the live phone-wrapped stream), and desktop-fit
|
||||
// resizes back to desktop dimensions.
|
||||
const [, setOverrideTick] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -376,17 +378,49 @@ export default function TerminalPane({
|
||||
|
||||
const unsubscribe = onOverrideChange((event) => {
|
||||
setOverrideTick((n) => n + 1)
|
||||
if (event.mode === 'desktop-fit') {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
// Why: pane IDs are per-tab, so resolve the affected PTY through this
|
||||
// tab's live transport bindings instead of global numeric pane IDs.
|
||||
const getAffectedPanes = (): ReturnType<typeof manager.getPanes> =>
|
||||
getOverrideAffectedPanes(
|
||||
manager.getPanes(),
|
||||
(paneId) => paneTransportsRef.current.get(paneId)?.getPtyId(),
|
||||
event.ptyId
|
||||
)
|
||||
if (event.mode === 'mobile-fit') {
|
||||
// Why: when mobile starts driving, the agent re-renders its output at
|
||||
// phone width and that phone-wrapped byte stream flows live into this
|
||||
// passive watcher's xterm. xterm must shrink to the phone dims now or
|
||||
// the wide desktop grid renders the narrow stream as overlapping,
|
||||
// garbled lines. safeFit honors the active override and parks xterm at
|
||||
// override.cols/rows, matching the incoming stream. rAF lets the DOM
|
||||
// settle before the resize; no loud fallback is needed because the
|
||||
// override branch of safeFit is itself the authoritative resize.
|
||||
// Why: override events fan out to every terminal tab; skip the rAF
|
||||
// unless this tab has a pane still parked at the wrong grid.
|
||||
const panesNeedingFit = getPanesNeedingOverrideFit(
|
||||
getAffectedPanes(),
|
||||
event.cols,
|
||||
event.rows
|
||||
)
|
||||
if (panesNeedingFit.length === 0) {
|
||||
return
|
||||
}
|
||||
// Why: pane IDs are per-tab, so resolve the affected PTY through this
|
||||
// tab's live transport bindings instead of global numeric pane IDs.
|
||||
const getAffectedPanes = (): ReturnType<typeof manager.getPanes> =>
|
||||
manager
|
||||
.getPanes()
|
||||
.filter((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() === event.ptyId)
|
||||
scheduleFitFrame(() => {
|
||||
for (const pane of getPanesNeedingOverrideFit(
|
||||
getAffectedPanes(),
|
||||
event.cols,
|
||||
event.rows
|
||||
)) {
|
||||
safeFit(pane)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.mode === 'desktop-fit') {
|
||||
// Why: fitAddon.fit() measures DOM dimensions, so it must run after
|
||||
// the browser has settled layout. Running synchronously inside the
|
||||
// IPC callback can produce stale measurements. rAF ensures the DOM
|
||||
@@ -1688,6 +1722,7 @@ export default function TerminalPane({
|
||||
|
||||
useTerminalKeyboardShortcuts({
|
||||
tabId,
|
||||
worktreeId,
|
||||
isActive,
|
||||
keyboardScopeRef: containerRef,
|
||||
managerRef,
|
||||
@@ -2982,8 +3017,6 @@ export default function TerminalPane({
|
||||
menuPaneIsExpanded={
|
||||
contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId
|
||||
}
|
||||
linkUrl={contextMenu.menuLinkUrl}
|
||||
onOpenLinkInDefaultBrowser={contextMenu.onOpenLinkInDefaultBrowser}
|
||||
onCopy={() => void contextMenu.onCopy()}
|
||||
onPaste={() => void contextMenu.onPaste()}
|
||||
onSplitRight={contextMenu.onSplitRight}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Play } from 'lucide-react'
|
||||
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
|
||||
|
||||
type TerminalQuickCommandMenuItemProps = {
|
||||
command: TerminalQuickCommand
|
||||
onSelect: (command: TerminalQuickCommand) => void
|
||||
}
|
||||
|
||||
export function TerminalQuickCommandMenuItem({
|
||||
command,
|
||||
onSelect
|
||||
}: TerminalQuickCommandMenuItemProps): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenuItem onSelect={() => onSelect(command)}>
|
||||
{isTerminalAgentQuickCommand(command) ? (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<AgentIcon agent={command.agent} size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<Play
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
fill="currentColor"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{command.label}</span>
|
||||
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
|
||||
<DropdownMenuShortcut className="shrink-0">
|
||||
{translate('auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d', 'Insert')}
|
||||
</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty'
|
||||
import {
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
@@ -137,6 +138,7 @@ export function matchFileSearchShortcut(
|
||||
|
||||
type KeyboardHandlersDeps = {
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
isActive: boolean
|
||||
keyboardScopeRef: React.RefObject<HTMLElement | null>
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
@@ -170,6 +172,7 @@ type KeyboardHandlersDeps = {
|
||||
*/
|
||||
export function useTerminalKeyboardShortcuts({
|
||||
tabId,
|
||||
worktreeId,
|
||||
isActive,
|
||||
keyboardScopeRef,
|
||||
managerRef,
|
||||
@@ -268,13 +271,36 @@ export function useTerminalKeyboardShortcuts({
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the active pane's live PTY session decides whether Ctrl+Arrow should
|
||||
// pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef.
|
||||
// Resolved lazily so session/runtime lookups stay off other keystrokes.
|
||||
const isLocalWindowsConptyPane = (): boolean => {
|
||||
const activePane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (!activePane) {
|
||||
return false
|
||||
}
|
||||
const storeState = useAppStore.getState()
|
||||
return isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows,
|
||||
userAgent: navigator.userAgent,
|
||||
state: storeState,
|
||||
worktreeId,
|
||||
tabId,
|
||||
paneId: activePane.id,
|
||||
paneCwd: paneCwdRef.current,
|
||||
fallbackCwd,
|
||||
transport: paneTransportsRef.current.get(activePane.id) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const action = resolveTerminalShortcutAction(
|
||||
e,
|
||||
isMac,
|
||||
macOptionAsAltRef.current,
|
||||
optionKeyLocation,
|
||||
isWindows,
|
||||
keybindings
|
||||
keybindings,
|
||||
isLocalWindowsConptyPane
|
||||
)
|
||||
if (!action) {
|
||||
return
|
||||
@@ -513,7 +539,8 @@ export function useTerminalKeyboardShortcuts({
|
||||
macOptionAsAltRef,
|
||||
keybindings,
|
||||
terminalShortcutPolicy,
|
||||
tabId
|
||||
tabId,
|
||||
worktreeId
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
|
||||
|
||||
function makePane(id: number, cols = 120, rows = 40): ManagedPane {
|
||||
return { id, terminal: { cols, rows } } as ManagedPane
|
||||
}
|
||||
|
||||
describe('getOverrideAffectedPanes', () => {
|
||||
it('returns only panes bound to the event PTY in this tab', () => {
|
||||
const panes = [makePane(1), makePane(2), makePane(3)]
|
||||
const bindings = new Map<number, string>([
|
||||
[1, 'pty-a'],
|
||||
[2, 'pty-b'],
|
||||
[3, 'pty-a']
|
||||
])
|
||||
|
||||
const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a')
|
||||
|
||||
expect(affected.map((pane) => pane.id)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('returns nothing for a watcher whose panes are bound to other PTYs', () => {
|
||||
const panes = [makePane(10), makePane(11)]
|
||||
const bindings = new Map<number, string>([
|
||||
[10, 'pty-x'],
|
||||
[11, 'pty-y']
|
||||
])
|
||||
|
||||
const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-z')
|
||||
|
||||
expect(affected).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unbound panes (resolver returns undefined)', () => {
|
||||
const panes = [makePane(1), makePane(2)]
|
||||
const bindings = new Map<number, string>([[1, 'pty-a']])
|
||||
|
||||
const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a')
|
||||
|
||||
expect(affected.map((pane) => pane.id)).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPanesNeedingOverrideFit', () => {
|
||||
it('returns only affected panes whose grid does not already match the override', () => {
|
||||
const panes = [makePane(1, 49, 20), makePane(2, 120, 40), makePane(3, 49, 21)]
|
||||
|
||||
const panesNeedingFit = getPanesNeedingOverrideFit(panes, 49, 20)
|
||||
|
||||
expect(panesNeedingFit.map((pane) => pane.id)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('returns nothing when every affected pane already matches the override', () => {
|
||||
const panes = [makePane(1, 49, 20), makePane(2, 49, 20)]
|
||||
|
||||
expect(getPanesNeedingOverrideFit(panes, 49, 20)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
// Why: a fit-override event names a PTY, but pane IDs are per-tab and the
|
||||
// override stream reaches every subscriber (including passive desktop
|
||||
// watchers). This resolves which of this tab's panes are bound to the
|
||||
// event's PTY via the tab's live transport bindings, so both the
|
||||
// mobile-fit and desktop-fit branches of the override listener refit the
|
||||
// same set of panes without colliding on global numeric pane IDs.
|
||||
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
|
||||
// Why: transports return string | null and the binding lookup yields
|
||||
// undefined for unbound panes, so the resolver tolerates both absent cases.
|
||||
export type PanePtyResolver = (paneId: number) => string | null | undefined
|
||||
|
||||
export function getOverrideAffectedPanes(
|
||||
panes: readonly ManagedPane[],
|
||||
resolvePtyId: PanePtyResolver,
|
||||
ptyId: string
|
||||
): ManagedPane[] {
|
||||
return panes.filter((pane) => resolvePtyId(pane.id) === ptyId)
|
||||
}
|
||||
|
||||
function paneNeedsOverrideFit(
|
||||
pane: Pick<ManagedPane, 'terminal'>,
|
||||
cols: number,
|
||||
rows: number
|
||||
): boolean {
|
||||
return pane.terminal.cols !== cols || pane.terminal.rows !== rows
|
||||
}
|
||||
|
||||
export function getPanesNeedingOverrideFit(
|
||||
panes: readonly ManagedPane[],
|
||||
cols: number,
|
||||
rows: number
|
||||
): ManagedPane[] {
|
||||
return panes.filter((pane) => paneNeedsOverrideFit(pane, cols, rows))
|
||||
}
|
||||
@@ -3120,6 +3120,45 @@ describe('connectPanePty', () => {
|
||||
expect(pane.container.dataset.ptyId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refits immediately when binding to a PTY with an active mobile-fit override', async () => {
|
||||
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
Object.defineProperty(pane.container, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () =>
|
||||
({
|
||||
width: 800,
|
||||
height: 400,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: 400
|
||||
}) as DOMRect
|
||||
})
|
||||
;(
|
||||
pane.fitAddon as unknown as { proposeDimensions: () => { cols: number; rows: number } }
|
||||
).proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
|
||||
pane.terminal.resize.mockImplementation((cols: number, rows: number) => {
|
||||
pane.terminal.cols = cols
|
||||
pane.terminal.rows = rows
|
||||
})
|
||||
setFitOverride('pty-fit', 'mobile-fit', 49, 20)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
|
||||
| ((ptyId: string) => void)
|
||||
| undefined
|
||||
expect(onPtySpawn).toBeTypeOf('function')
|
||||
onPtySpawn?.('pty-fit')
|
||||
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20)
|
||||
expect(pane.terminal.cols).toBe(49)
|
||||
expect(pane.terminal.rows).toBe(20)
|
||||
})
|
||||
|
||||
it('continues post-spawn size reconcile after a transient mobile presence lock', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
|
||||
@@ -1847,6 +1847,11 @@ export function connectPanePty(
|
||||
const setPanePtyFitBinding = (ptyId: string): void => {
|
||||
bindPanePtyId(pane.id, ptyId, deps.tabId)
|
||||
pane.container.dataset.ptyId = ptyId
|
||||
// Why: override hydration can arrive before this pane knows its PTY. Once
|
||||
// data-pty-id is bound, safeFit can park xterm at the held phone grid.
|
||||
if (getFitOverrideForPty(ptyId)) {
|
||||
safeFit(pane)
|
||||
}
|
||||
}
|
||||
let activePanePtyBinding: string | null = null
|
||||
// Why: bind time lets async liveness reconcile ignore a request started
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import {
|
||||
isLocalWindowsConptyPaneForCtrlArrow,
|
||||
type TerminalCtrlArrowConptyState
|
||||
} from './terminal-ctrl-arrow-conpty'
|
||||
import type { PtyTransport } from './pty-transport-types'
|
||||
|
||||
const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
function state(
|
||||
overrides: Partial<TerminalCtrlArrowConptyState> = {}
|
||||
): TerminalCtrlArrowConptyState {
|
||||
return {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
settings: null,
|
||||
tabsByWorktree: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function localTransport(
|
||||
metadata: { cwd?: string; shellOverride?: string } = {}
|
||||
): Pick<PtyTransport, 'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata'> {
|
||||
return {
|
||||
getPtyId: () => 'pty-1',
|
||||
getConnectionId: () => null,
|
||||
getLocalSessionMetadata: () => metadata
|
||||
}
|
||||
}
|
||||
|
||||
function remoteRuntimeTransport(): Pick<
|
||||
PtyTransport,
|
||||
'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata'
|
||||
> {
|
||||
return {
|
||||
getPtyId: () => 'remote:env-1@@terminal-1',
|
||||
getConnectionId: () => null,
|
||||
getLocalSessionMetadata: () => null
|
||||
}
|
||||
}
|
||||
|
||||
describe('isLocalWindowsConptyPaneForCtrlArrow', () => {
|
||||
it('uses live local session metadata for synthetic floating terminals with no repo record', () => {
|
||||
expect(
|
||||
isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows: true,
|
||||
userAgent: WINDOWS_UA,
|
||||
state: state({
|
||||
tabsByWorktree: {
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'tab-1', shellOverride: 'powershell.exe' }]
|
||||
}
|
||||
}),
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
tabId: 'tab-1',
|
||||
paneId: 1,
|
||||
paneCwd: new Map(),
|
||||
fallbackCwd: 'C:\\Users\\me',
|
||||
transport: localTransport({ cwd: 'C:\\Users\\me', shellOverride: 'powershell.exe' })
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an already-launched local PowerShell pane local after store ownership changes', () => {
|
||||
const worktreeId = 'repo-1::C:\\repo'
|
||||
expect(
|
||||
isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows: true,
|
||||
userAgent: WINDOWS_UA,
|
||||
state: state({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [{ id: 'tab-1', shellOverride: 'wsl.exe' }]
|
||||
}
|
||||
}),
|
||||
worktreeId,
|
||||
tabId: 'tab-1',
|
||||
paneId: 1,
|
||||
paneCwd: new Map([[1, { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me', confirmed: true }]]),
|
||||
fallbackCwd: '\\\\wsl.localhost\\Ubuntu\\home\\me',
|
||||
transport: localTransport({ cwd: 'C:\\repo', shellOverride: 'powershell.exe' })
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat a live remote-runtime terminal as local ConPTY', () => {
|
||||
expect(
|
||||
isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows: true,
|
||||
userAgent: WINDOWS_UA,
|
||||
state: state(),
|
||||
worktreeId: 'repo-1::/remote/repo',
|
||||
tabId: 'tab-1',
|
||||
paneId: 1,
|
||||
paneCwd: new Map([[1, { cwd: '/remote/repo', confirmed: true }]]),
|
||||
fallbackCwd: '/remote/repo',
|
||||
transport: remoteRuntimeTransport()
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays conservative for unresolved repo-backed panes before a live session exists', () => {
|
||||
expect(
|
||||
isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows: true,
|
||||
userAgent: WINDOWS_UA,
|
||||
state: state(),
|
||||
worktreeId: 'missing-repo::/home/me/repo',
|
||||
tabId: 'tab-1',
|
||||
paneId: 1,
|
||||
paneCwd: new Map([[1, { cwd: '/home/me/repo', confirmed: true }]]),
|
||||
fallbackCwd: '/home/me/repo',
|
||||
transport: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
type WorktreeRuntimeOwnerState
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import type { PtyTransport } from './pty-transport-types'
|
||||
|
||||
const REMOTE_RUNTIME_PTY_ID_PREFIX = 'remote:'
|
||||
|
||||
type TerminalTabShellState = {
|
||||
tabsByWorktree: Record<
|
||||
string,
|
||||
readonly { id: string; shellOverride?: string | null }[] | undefined
|
||||
>
|
||||
}
|
||||
|
||||
export type TerminalCtrlArrowConptyState = Parameters<typeof getConnectionIdFromState>[0] &
|
||||
WorktreeRuntimeOwnerState &
|
||||
TerminalTabShellState
|
||||
|
||||
type TerminalCtrlArrowConptyTransport = Pick<
|
||||
PtyTransport,
|
||||
'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata'
|
||||
>
|
||||
|
||||
type TerminalCtrlArrowConptyArgs = {
|
||||
isWindows: boolean
|
||||
userAgent: string
|
||||
state: TerminalCtrlArrowConptyState
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
paneId: number
|
||||
paneCwd: PaneCwdMap
|
||||
fallbackCwd: string
|
||||
transport: TerminalCtrlArrowConptyTransport | null
|
||||
}
|
||||
|
||||
function isRemoteRuntimePtyId(ptyId: string): boolean {
|
||||
return ptyId.startsWith(REMOTE_RUNTIME_PTY_ID_PREFIX)
|
||||
}
|
||||
|
||||
export function isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows,
|
||||
userAgent,
|
||||
state,
|
||||
worktreeId,
|
||||
tabId,
|
||||
paneId,
|
||||
paneCwd,
|
||||
fallbackCwd,
|
||||
transport
|
||||
}: TerminalCtrlArrowConptyArgs): boolean {
|
||||
if (!isWindows) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ptyId = transport?.getPtyId() ?? null
|
||||
if (ptyId !== null && isRemoteRuntimePtyId(ptyId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionMetadata = transport?.getLocalSessionMetadata?.()
|
||||
const hasLiveLocalSession =
|
||||
ptyId !== null && sessionMetadata !== null && sessionMetadata !== undefined
|
||||
const transportConnectionId = transport?.getConnectionId?.()
|
||||
const connectionId = hasLiveLocalSession
|
||||
? null
|
||||
: transportConnectionId === undefined
|
||||
? getConnectionIdFromState(state, worktreeId)
|
||||
: transportConnectionId
|
||||
const tabShellOverride = state.tabsByWorktree[worktreeId]?.find(
|
||||
(candidate) => candidate.id === tabId
|
||||
)?.shellOverride
|
||||
const executionHostId: ExecutionHostId = hasLiveLocalSession
|
||||
? LOCAL_EXECUTION_HOST_ID
|
||||
: getExecutionHostIdForWorktree(state, worktreeId)
|
||||
|
||||
return isLocalNativeWindowsConpty({
|
||||
userAgent,
|
||||
connectionId,
|
||||
cwd: sessionMetadata?.cwd ?? paneCwd.get(paneId)?.cwd ?? fallbackCwd,
|
||||
shellOverride: sessionMetadata?.shellOverride ?? tabShellOverride,
|
||||
executionHostId
|
||||
})
|
||||
}
|
||||
@@ -18,10 +18,9 @@ function event(overrides: Partial<TerminalShortcutEvent>): TerminalShortcutEvent
|
||||
}
|
||||
|
||||
describe('non-mac Ctrl+Left/Right word-nav', () => {
|
||||
// Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ to
|
||||
// word-nav. xterm.js emits \e[1;5D / \e[1;5C which default readline doesn't
|
||||
// map, so translate to \eb / \ef (same bytes as our Alt+Arrow rule).
|
||||
it('translates Ctrl+←/→ on Windows/Linux to readline \\eb / \\ef', () => {
|
||||
// Linux and remote/WSL readline shells don't bind the \e[1;5D / \e[1;5C that
|
||||
// xterm.js emits, so translate to \eb / \ef (same bytes as our Alt+Arrow rule).
|
||||
it('translates Ctrl+←/→ on Linux to readline \\eb / \\ef', () => {
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }),
|
||||
@@ -36,6 +35,64 @@ describe('non-mac Ctrl+Left/Right word-nav', () => {
|
||||
).toEqual({ type: 'sendInput', data: '\x1bf' })
|
||||
})
|
||||
|
||||
// Local Windows ConPTY shells (PowerShell/cmd via PSReadLine) already bind
|
||||
// Ctrl+←/→ to word-nav and self-insert a stray "b"/"f" when fed \eb/\ef
|
||||
// (Escape→RevertLine + self-insert), so the policy must defer to xterm's
|
||||
// native \e[1;5D / \e[1;5C there. Signalled via the isLocalWindowsConptyPane
|
||||
// getter (7th arg).
|
||||
it('does NOT translate Ctrl+←/→ for a local Windows ConPTY pane', () => {
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
() => true
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
() => true
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// A Windows client SSH'd into Linux (or running WSL) is NOT a local native
|
||||
// Windows ConPTY, so the getter returns false and the readline translation
|
||||
// must still apply — otherwise word-nav silently breaks on the remote shell.
|
||||
it('still translates Ctrl+←/→ on Windows when the pane is not local ConPTY (SSH/WSL)', () => {
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
() => false
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x1bb' })
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
() => false
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x1bf' })
|
||||
})
|
||||
|
||||
it('does not translate Ctrl+Arrow on macOS (reserved by OS)', () => {
|
||||
// Mac uses Cmd+Arrow for line-nav and Option+Arrow for word-nav.
|
||||
// Ctrl+Arrow is the macOS Mission Control / Spaces chord.
|
||||
|
||||
@@ -53,7 +53,12 @@ export function resolveTerminalShortcutAction(
|
||||
macOptionAsAlt: MacOptionAsAlt = 'false',
|
||||
optionKeyLocation: number = 0,
|
||||
isWindows: boolean = false,
|
||||
keybindings?: KeybindingOverrides
|
||||
keybindings?: KeybindingOverrides,
|
||||
// Why: lazily reports whether the active pane is a local native Windows
|
||||
// ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow
|
||||
// word-nav rule below, so the execution-host lookup it performs stays off the
|
||||
// hot path for every other keystroke.
|
||||
isLocalWindowsConptyPane?: () => boolean
|
||||
): TerminalShortcutAction | null {
|
||||
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
|
||||
if (!event.repeat) {
|
||||
@@ -206,12 +211,21 @@ export function resolveTerminalShortcutAction(
|
||||
!event.shiftKey &&
|
||||
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
|
||||
) {
|
||||
// Why: Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ for
|
||||
// word navigation on Linux/Windows — but xterm.js emits \e[1;5D / \e[1;5C,
|
||||
// which default readline (bash, zsh) does not bind to backward-word /
|
||||
// forward-word. Translate to \eb / \ef (same bytes as our Alt+Arrow rule)
|
||||
// so Ctrl+←/→ works for word-nav matching user expectations on those
|
||||
// platforms without requiring a custom inputrc.
|
||||
// Why: local Windows ConPTY shells (PowerShell/cmd via PSReadLine) already
|
||||
// bind Ctrl+←/→ to word-nav, and they treat \eb/\ef (Alt+b/f) as
|
||||
// Escape→RevertLine followed by a self-inserted "b"/"f" — so the translation
|
||||
// below prints a stray letter instead of moving the cursor (issue: Ctrl+→
|
||||
// types "b"/"f" in PowerShell). Defer to xterm's native \e[1;5D / \e[1;5C
|
||||
// there. Remote/WSL panes on a Windows client run readline and still need
|
||||
// the translation, so this is gated on a genuine local native ConPTY, not
|
||||
// merely on the client being Windows.
|
||||
if (isLocalWindowsConptyPane?.()) {
|
||||
return null
|
||||
}
|
||||
// Why: default readline (bash, zsh) does not bind the \e[1;5D / \e[1;5C that
|
||||
// xterm.js emits for Ctrl+←/→, so Linux and remote/WSL shells need the
|
||||
// translation to \eb / \ef (same bytes as our Alt+Arrow rule) for word-nav
|
||||
// to work without a custom inputrc.
|
||||
//
|
||||
// Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces
|
||||
// navigation at the OS level and should never reach the app.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
extractTerminalHttpLinks,
|
||||
getTerminalHttpLinkAtBufferPosition,
|
||||
getTerminalHttpLinkForMouseEvent,
|
||||
TERMINAL_HTTP_URL_MAX_LENGTH
|
||||
} from './terminal-url-link-hit-testing'
|
||||
|
||||
@@ -49,127 +47,3 @@ describe('extractTerminalHttpLinks', () => {
|
||||
expect(matchAllSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal HTTP link hit testing', () => {
|
||||
type TestBufferLine = {
|
||||
isWrapped: boolean
|
||||
length: number
|
||||
translateToString: (
|
||||
trimRight?: boolean,
|
||||
startColumn?: number,
|
||||
endColumn?: number,
|
||||
outColumns?: number[]
|
||||
) => string
|
||||
}
|
||||
|
||||
function defaultColumnsForText(text: string): number[] {
|
||||
return Array.from({ length: text.length + 1 }, (_value, index) => index)
|
||||
}
|
||||
|
||||
function makeBufferLine(
|
||||
text: string,
|
||||
options: { isWrapped?: boolean; columns?: number[] } = {}
|
||||
): TestBufferLine {
|
||||
const columns = options.columns ?? defaultColumnsForText(text)
|
||||
return {
|
||||
isWrapped: options.isWrapped ?? false,
|
||||
length: text.length,
|
||||
translateToString: (
|
||||
_trimRight?: boolean,
|
||||
startColumn = 0,
|
||||
endColumn = text.length,
|
||||
outColumns?: number[]
|
||||
) => {
|
||||
if (outColumns) {
|
||||
outColumns.length = 0
|
||||
for (let index = startColumn; index <= endColumn; index++) {
|
||||
outColumns.push(columns[index] ?? index)
|
||||
}
|
||||
}
|
||||
return text.slice(startColumn, endColumn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeBuffer(
|
||||
rows: TestBufferLine[]
|
||||
): Parameters<typeof getTerminalHttpLinkAtBufferPosition>[0] {
|
||||
return { getLine: (y: number) => rows[y] } as Parameters<
|
||||
typeof getTerminalHttpLinkAtBufferPosition
|
||||
>[0]
|
||||
}
|
||||
|
||||
it('returns the URL at a buffer position without opening it', () => {
|
||||
const line = 'Open https://example.com/docs?q=orca now'
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkAtBufferPosition(
|
||||
makeBuffer([makeBufferLine(line)]),
|
||||
{ x: line.indexOf('example') + 1, y: 1 },
|
||||
80
|
||||
)
|
||||
).toBe('https://example.com/docs?q=orca')
|
||||
})
|
||||
|
||||
it('returns null when the buffer position is outside the URL', () => {
|
||||
const line = 'Open https://example.com/docs now'
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkAtBufferPosition(makeBuffer([makeBufferLine(line)]), { x: 2, y: 1 }, 80)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// Why: the context-menu "Open in Default Browser" item hands this URL to
|
||||
// shell.openExternal, so a dangerous scheme under the cursor must never be
|
||||
// surfaced as an openable link.
|
||||
it.each(['file:///etc/passwd', 'javascript:alert(1)', 'smb://host/share', 'vscode://x'])(
|
||||
'returns null for the non-http scheme %s',
|
||||
(dangerousUrl) => {
|
||||
const line = `Open ${dangerousUrl} now`
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkAtBufferPosition(
|
||||
makeBuffer([makeBufferLine(line)]),
|
||||
{ x: line.indexOf(dangerousUrl) + 1, y: 1 },
|
||||
80
|
||||
)
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('returns wrapped URLs from mouse coordinates', () => {
|
||||
const rows = [
|
||||
makeBufferLine('Visit https://exa'),
|
||||
makeBufferLine('mple.com/docs', { isWrapped: true })
|
||||
]
|
||||
const screen = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 200,
|
||||
height: 40
|
||||
})
|
||||
}
|
||||
const terminal = {
|
||||
cols: 20,
|
||||
rows: 2,
|
||||
element: { querySelector: () => screen },
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
getLine: (y: number) => rows[y]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
getTerminalHttpLinkForMouseEvent(
|
||||
terminal as unknown as Parameters<typeof getTerminalHttpLinkForMouseEvent>[0],
|
||||
{
|
||||
clientX: 30,
|
||||
clientY: 45
|
||||
}
|
||||
)
|
||||
).toBe('https://example.com/docs')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,7 +191,7 @@ function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
|
||||
|
||||
function getBufferPositionForTerminalMouseEvent(
|
||||
terminal: Terminal,
|
||||
event: Pick<MouseEvent, 'clientX' | 'clientY'>
|
||||
event: MouseEvent
|
||||
): { x: number; y: number } | null {
|
||||
const screenElement = getTerminalScreenElement(terminal)
|
||||
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
|
||||
@@ -260,34 +260,9 @@ export function openHttpLinkAtBufferPosition(
|
||||
terminalColumns: number,
|
||||
deps: UrlLinkHitTestDeps
|
||||
): boolean {
|
||||
const url = getTerminalHttpLinkAtBufferPosition(buffer, position, terminalColumns)
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
|
||||
openTerminalHttpLink(url, deps)
|
||||
return true
|
||||
}
|
||||
|
||||
export function getTerminalHttpLinkForMouseEvent(
|
||||
terminal: Terminal,
|
||||
event: Pick<MouseEvent, 'clientX' | 'clientY'>
|
||||
): string | null {
|
||||
const position = getBufferPositionForTerminalMouseEvent(terminal, event)
|
||||
if (!position) {
|
||||
return null
|
||||
}
|
||||
return getTerminalHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)
|
||||
}
|
||||
|
||||
export function getTerminalHttpLinkAtBufferPosition(
|
||||
buffer: { getLine(y: number): IBufferLine | undefined },
|
||||
position: { x: number; y: number },
|
||||
terminalColumns: number
|
||||
): string | null {
|
||||
const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y)
|
||||
if (logicalLines.length === 0) {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
for (const logicalLine of logicalLines) {
|
||||
@@ -296,11 +271,12 @@ export function getTerminalHttpLinkAtBufferPosition(
|
||||
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
|
||||
continue
|
||||
}
|
||||
return parsed.url
|
||||
openTerminalHttpLink(parsed.url, deps)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
|
||||
|
||||
@@ -41,8 +41,6 @@ import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import { copyTerminalHandleForPane } from './terminal-handle-copy'
|
||||
import { getTerminalHttpLinkForMouseEvent } from './terminal-url-link-hit-testing'
|
||||
import { openHttpLink } from '@/lib/http-link-routing'
|
||||
|
||||
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
||||
@@ -83,8 +81,6 @@ type TerminalMenuState = {
|
||||
menuOpenedAtRef: React.RefObject<number>
|
||||
paneCount: number
|
||||
menuPaneId: number | null
|
||||
menuLinkUrl: string | null
|
||||
onOpenLinkInDefaultBrowser: () => void
|
||||
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
|
||||
onCopy: () => Promise<void>
|
||||
@@ -128,7 +124,6 @@ export function useTerminalPaneContextMenu({
|
||||
const menuOpenedAtRef = useRef(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [point, setPoint] = useState({ x: 0, y: 0 })
|
||||
const [menuLinkUrl, setMenuLinkUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => {
|
||||
@@ -187,17 +182,6 @@ export function useTerminalPaneContextMenu({
|
||||
pane.terminal.focus()
|
||||
}
|
||||
|
||||
const onOpenLinkInDefaultBrowser = (): void => {
|
||||
if (!menuLinkUrl) {
|
||||
return
|
||||
}
|
||||
// Why: route through the shared funnel (not shell.openUrl directly) so this
|
||||
// matches shift+click's system-browser path, including the loopback
|
||||
// worktree-label rewrite for local dev-server links.
|
||||
openHttpLink(menuLinkUrl, { worktreeId, forceSystemBrowser: true })
|
||||
resolveMenuPane()?.terminal.focus()
|
||||
}
|
||||
|
||||
const getShortcutPlatform = (): NodeJS.Platform => {
|
||||
if (navigator.userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
@@ -488,7 +472,6 @@ export function useTerminalPaneContextMenu({
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
contextPaneIdRef.current = null
|
||||
setMenuLinkUrl(null)
|
||||
return
|
||||
}
|
||||
const clickedPane =
|
||||
@@ -516,11 +499,6 @@ export function useTerminalPaneContextMenu({
|
||||
return
|
||||
}
|
||||
|
||||
// Why: only hit-test the link once the menu is actually opening; the Windows
|
||||
// copy/paste path above returns without a menu and needs no link lookup.
|
||||
setMenuLinkUrl(
|
||||
clickedPane ? getTerminalHttpLinkForMouseEvent(clickedPane.terminal, event.nativeEvent) : null
|
||||
)
|
||||
menuOpenedAtRef.current = Date.now()
|
||||
const bounds = boundsElement.getBoundingClientRect()
|
||||
setPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
|
||||
@@ -532,14 +510,12 @@ export function useTerminalPaneContextMenu({
|
||||
if (!manager) {
|
||||
event.preventDefault()
|
||||
contextPaneIdRef.current = null
|
||||
setMenuLinkUrl(null)
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
event.preventDefault()
|
||||
contextPaneIdRef.current = null
|
||||
setMenuLinkUrl(null)
|
||||
return
|
||||
}
|
||||
const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
|
||||
@@ -568,8 +544,6 @@ export function useTerminalPaneContextMenu({
|
||||
menuOpenedAtRef,
|
||||
paneCount,
|
||||
menuPaneId,
|
||||
menuLinkUrl,
|
||||
onOpenLinkInDefaultBrowser,
|
||||
onContextMenuCapture,
|
||||
onPaneTitleContextMenu,
|
||||
onCopy,
|
||||
|
||||
@@ -209,6 +209,8 @@ describe('useComposerState host-context boundaries', () => {
|
||||
'const applyLinkedWorkItem = useCallback'
|
||||
)
|
||||
expect(directLookup).toContain('sourceContext: selectedRepoGitHubSourceContext')
|
||||
expect(directLookup).toContain('lookupGitHubWorkItemByOwnerRepoForSource')
|
||||
expect(directLookup).toContain('type: normalizedLinkQuery.directLink.type')
|
||||
|
||||
const submitLookup = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
@@ -236,7 +238,11 @@ describe('useComposerState host-context boundaries', () => {
|
||||
'const intent = getSmartGitHubSubmitIntent(name)'
|
||||
)
|
||||
expect(selectedPrSubmitLookup).toContain('smartGitHubPrStartPointSelectionRef.current')
|
||||
expect(selectedPrSubmitLookup).toContain("linkedWorkItem.type === 'pr'")
|
||||
expect(selectedPrSubmitLookup).toContain("linkedWorkItemIdentity?.type === 'pr'")
|
||||
expect(selectedPrSubmitLookup).toContain("startPointIdentity?.type === 'pr'")
|
||||
expect(selectedPrSubmitLookup).toContain(
|
||||
'startPointIdentity.number === linkedWorkItemIdentity.number'
|
||||
)
|
||||
expect(selectedPrSubmitLookup).toContain('resolveGitHubPrStartPointForRepo')
|
||||
expect(selectedPrSubmitLookup.indexOf('resolveGitHubPrStartPointForRepo')).toBeLessThan(
|
||||
selectedPrSubmitLookup.indexOf("return { kind: 'none' }")
|
||||
@@ -421,8 +427,12 @@ describe('useComposerState host-context boundaries', () => {
|
||||
)
|
||||
expect(projectGroupSmartHandlers).toContain('setLinkedGitLabIssue(null)')
|
||||
expect(projectGroupSmartHandlers).toContain('setLinkedGitLabMR(null)')
|
||||
expect(projectGroupSmartHandlers).toContain("setLinkedIssue('')")
|
||||
expect(projectGroupSmartHandlers).toContain('setLinkedPR(null)')
|
||||
expect(projectGroupSmartHandlers).toContain(
|
||||
"setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '')"
|
||||
)
|
||||
expect(projectGroupSmartHandlers).toContain(
|
||||
"setLinkedPR(identity.type === 'pr' ? identity.number : null)"
|
||||
)
|
||||
})
|
||||
|
||||
it('disables repo-backed folder smart lookup when a folder target has no source repos', () => {
|
||||
|
||||
@@ -95,6 +95,10 @@ import {
|
||||
lookupGitHubWorkItemByOwnerRepoForSource,
|
||||
lookupGitHubWorkItemForSource
|
||||
} from '@/lib/github-work-item-source-lookup'
|
||||
import {
|
||||
resolveGitHubWorkItemIdentity,
|
||||
type GitHubWorkItemIdentity
|
||||
} from '@/lib/github-work-item-identity'
|
||||
import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point'
|
||||
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
|
||||
import {
|
||||
@@ -475,6 +479,38 @@ function getLinkedWorkItemSeedName(item: LinkedWorkItemSummary | null | undefine
|
||||
return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
|
||||
}
|
||||
|
||||
function getGitHubLinkedWorkItemIdentity(
|
||||
item: LinkedWorkItemSummary | null | undefined
|
||||
): GitHubWorkItemIdentity | null {
|
||||
if (
|
||||
!item ||
|
||||
getLinkedWorkItemProvider(item) !== 'github' ||
|
||||
(item.type !== 'issue' && item.type !== 'pr')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveGitHubWorkItemIdentity({
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
url: item.url
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeGitHubLinkedWorkItem(
|
||||
item: LinkedWorkItemSummary | null | undefined
|
||||
): LinkedWorkItemSummary | null {
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
const identity = getGitHubLinkedWorkItemIdentity(item)
|
||||
if (!identity || (identity.type === item.type && identity.number === item.number)) {
|
||||
return item
|
||||
}
|
||||
|
||||
return { ...item, type: identity.type, number: identity.number }
|
||||
}
|
||||
|
||||
export function getInitialAutoManagedWorkspaceName({
|
||||
draftName,
|
||||
draftLinkedWorkItem,
|
||||
@@ -905,10 +941,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const [attachmentPaths, setAttachmentPaths] = useState<string[]>(
|
||||
persistDraft ? (newWorkspaceDraft?.attachments ?? []) : []
|
||||
)
|
||||
const initialLinkedWorkItemSeed = normalizeGitHubLinkedWorkItem(initialLinkedWorkItem)
|
||||
const draftLinkedWorkItemSeed = persistDraft
|
||||
? normalizeGitHubLinkedWorkItem(newWorkspaceDraft?.linkedWorkItem)
|
||||
: null
|
||||
const linkedWorkItemSeed = persistDraft
|
||||
? (draftLinkedWorkItemSeed ?? initialLinkedWorkItemSeed)
|
||||
: initialLinkedWorkItemSeed
|
||||
const linkedWorkItemSeedIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItemSeed)
|
||||
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(
|
||||
persistDraft
|
||||
? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem)
|
||||
: initialLinkedWorkItem
|
||||
() => linkedWorkItemSeed
|
||||
)
|
||||
const taskSourceContext = useMemo(() => {
|
||||
if (
|
||||
@@ -982,6 +1024,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
})
|
||||
}, [projects, selectedRepo, selectedRepoIsGit, selectedWorkspaceTarget, taskSourceContext])
|
||||
const [linkedIssue, setLinkedIssue] = useState<string>(() => {
|
||||
if (linkedWorkItemSeedIdentity?.type === 'issue') {
|
||||
return String(linkedWorkItemSeedIdentity.number)
|
||||
}
|
||||
if (persistDraft && newWorkspaceDraft?.linkedIssue) {
|
||||
return newWorkspaceDraft.linkedIssue
|
||||
}
|
||||
@@ -994,6 +1039,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
return ''
|
||||
})
|
||||
const [linkedPR, setLinkedPR] = useState<number | null>(() => {
|
||||
if (linkedWorkItemSeedIdentity?.type === 'pr') {
|
||||
return linkedWorkItemSeedIdentity.number
|
||||
}
|
||||
if (linkedWorkItemSeedIdentity?.type === 'issue') {
|
||||
return null
|
||||
}
|
||||
if (persistDraft && newWorkspaceDraft?.linkedPR !== undefined) {
|
||||
return newWorkspaceDraft.linkedPR
|
||||
}
|
||||
@@ -1134,9 +1185,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const lastAutoNameRef = useRef<string>(
|
||||
getInitialAutoManagedWorkspaceName({
|
||||
draftName: persistDraft ? newWorkspaceDraft?.name : null,
|
||||
draftLinkedWorkItem: persistDraft ? newWorkspaceDraft?.linkedWorkItem : null,
|
||||
draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null,
|
||||
initialName,
|
||||
initialLinkedWorkItem
|
||||
initialLinkedWorkItem: initialLinkedWorkItemSeed
|
||||
})
|
||||
)
|
||||
const nameRef = useRef<string>(name)
|
||||
@@ -1922,16 +1973,28 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
let cancelled = false
|
||||
setLinkDirectLoading(true)
|
||||
// Why: Superset lets users paste a full GitHub URL or type a raw issue/PR
|
||||
// number and still get a concrete selectable result. Orca mirrors that by
|
||||
// resolving direct lookups against the selected repo instead of requiring a
|
||||
// text match in the recent-items list.
|
||||
// number and still get a concrete selectable result. Full URLs carry
|
||||
// issue-vs-PR intent, so preserve the URL route instead of probing by
|
||||
// number only.
|
||||
const lookupRepoId = selectedRepo.id
|
||||
void lookupGitHubWorkItemForSource({
|
||||
repoPath: selectedRepo.path,
|
||||
repoId: selectedRepo.id,
|
||||
sourceContext: selectedRepoGitHubSourceContext,
|
||||
number: normalizedLinkQuery.directNumber
|
||||
})
|
||||
const lookup =
|
||||
normalizedLinkQuery.directLink !== undefined
|
||||
? lookupGitHubWorkItemByOwnerRepoForSource({
|
||||
repoPath: selectedRepo.path,
|
||||
repoId: selectedRepo.id,
|
||||
sourceContext: selectedRepoGitHubSourceContext,
|
||||
owner: normalizedLinkQuery.directLink.slug.owner,
|
||||
repo: normalizedLinkQuery.directLink.slug.repo,
|
||||
number: normalizedLinkQuery.directLink.number,
|
||||
type: normalizedLinkQuery.directLink.type
|
||||
})
|
||||
: lookupGitHubWorkItemForSource({
|
||||
repoPath: selectedRepo.path,
|
||||
repoId: selectedRepo.id,
|
||||
sourceContext: selectedRepoGitHubSourceContext,
|
||||
number: normalizedLinkQuery.directNumber
|
||||
})
|
||||
void lookup
|
||||
.then((item) => {
|
||||
if (!cancelled) {
|
||||
setLinkDirectItem(
|
||||
@@ -1954,6 +2017,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
normalizedLinkQuery.directLink,
|
||||
linkPopoverOpen,
|
||||
normalizedLinkQuery.directNumber,
|
||||
selectedRepo,
|
||||
@@ -1963,24 +2027,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
|
||||
const applyLinkedWorkItem = useCallback(
|
||||
(item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => {
|
||||
if (item.type === 'issue') {
|
||||
setLinkedIssue(String(item.number))
|
||||
const identity = resolveGitHubWorkItemIdentity(item)
|
||||
const normalizedItem: GitHubWorkItem = {
|
||||
...item,
|
||||
type: identity.type,
|
||||
number: identity.number
|
||||
}
|
||||
if (identity.type === 'issue') {
|
||||
setLinkedIssue(String(identity.number))
|
||||
setLinkedPR(null)
|
||||
} else {
|
||||
setLinkedIssue('')
|
||||
setLinkedPR(item.number)
|
||||
setLinkedPR(identity.number)
|
||||
}
|
||||
setLinkedGitLabIssue(null)
|
||||
setLinkedGitLabMR(null)
|
||||
setLinkedWorkItem({
|
||||
type: item.type,
|
||||
type: identity.type,
|
||||
provider: 'github',
|
||||
number: item.number,
|
||||
number: identity.number,
|
||||
title: item.title,
|
||||
url: item.url
|
||||
})
|
||||
const suggestedName =
|
||||
getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
|
||||
getLinkedWorkItemWorkspaceName(normalizedItem)?.seedName ??
|
||||
getLinkedWorkItemSuggestedName(normalizedItem)
|
||||
// Why: a pasted URL/#123 in the field is the lookup query that found
|
||||
// this item, not a deliberate name — replace it with the title-derived
|
||||
// name or it silently becomes a slugified-URL workspace name.
|
||||
@@ -2002,20 +2073,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
useCallback(async (): Promise<PendingSmartGitHubSubmitResolution> => {
|
||||
if (linkedWorkItem) {
|
||||
const startPointSelection = smartGitHubPrStartPointSelectionRef.current
|
||||
const linkedWorkItemIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem)
|
||||
const startPointIdentity = startPointSelection
|
||||
? resolveGitHubWorkItemIdentity(startPointSelection.item)
|
||||
: null
|
||||
if (
|
||||
!isProjectGroupTarget &&
|
||||
linkedWorkItem.type === 'pr' &&
|
||||
linkedWorkItemIdentity?.type === 'pr' &&
|
||||
startPointIdentity?.type === 'pr' &&
|
||||
getLinkedWorkItemProvider(linkedWorkItem) === 'github' &&
|
||||
selectedRepo &&
|
||||
selectedRepoIsGit &&
|
||||
startPointSelection?.repoId === selectedRepo.id &&
|
||||
startPointSelection.item.number === linkedWorkItem.number
|
||||
startPointIdentity.number === linkedWorkItemIdentity.number
|
||||
) {
|
||||
const selectedPrStartPoint =
|
||||
startPointSelection.resolved ??
|
||||
(await resolveGitHubPrStartPointForRepo({
|
||||
repoId: selectedRepo.id,
|
||||
prNumber: startPointSelection.item.number,
|
||||
prNumber: startPointIdentity.number,
|
||||
settings: getSettingsForRepoRuntimeOwner(
|
||||
{ repos: [selectedRepo], settings },
|
||||
selectedRepo.id
|
||||
@@ -2102,11 +2178,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
throw new Error('Could not resolve the GitHub item before creating the workspace.')
|
||||
}
|
||||
|
||||
const itemIdentity = resolveGitHubWorkItemIdentity(item)
|
||||
const prStartPoint =
|
||||
!isProjectGroupTarget && item.type === 'pr' && selectedRepo && selectedRepoIsGit
|
||||
!isProjectGroupTarget && itemIdentity.type === 'pr' && selectedRepo && selectedRepoIsGit
|
||||
? await resolveGitHubPrStartPointForRepo({
|
||||
repoId: selectedRepo.id,
|
||||
prNumber: item.number,
|
||||
prNumber: itemIdentity.number,
|
||||
settings: getSettingsForRepoRuntimeOwner(
|
||||
{ repos: [selectedRepo], settings },
|
||||
selectedRepo.id
|
||||
@@ -2757,8 +2834,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// worktree's comment should surface (`orca worktree current`, sidebar).
|
||||
// Prefill the note if it's empty or still equal to a prior auto-fill, so
|
||||
// we don't overwrite anything the user has typed.
|
||||
if (item.type === 'pr') {
|
||||
const suggestedNote = `PR #${item.number} — ${item.title}`
|
||||
const identity = resolveGitHubWorkItemIdentity(item)
|
||||
if (identity.type === 'pr') {
|
||||
const suggestedNote = `PR #${identity.number} — ${item.title}`
|
||||
const currentNote = noteRef.current
|
||||
if (!currentNote.trim() || currentNote === lastAutoNoteRef.current) {
|
||||
setNote(suggestedNote)
|
||||
@@ -2800,10 +2878,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
|
||||
const handleSmartGitHubItemSelect = useCallback(
|
||||
(item: GitHubWorkItem): void => {
|
||||
const identity = resolveGitHubWorkItemIdentity(item)
|
||||
const normalizedItem: GitHubWorkItem = {
|
||||
...item,
|
||||
type: identity.type,
|
||||
number: identity.number
|
||||
}
|
||||
if (isProjectGroupTarget) {
|
||||
const linkedItem = toGitHubLinkedWorkItem(item)
|
||||
setLinkedIssue(String(item.number))
|
||||
setLinkedPR(item.type === 'pr' ? item.number : null)
|
||||
const linkedItem = toGitHubLinkedWorkItem(normalizedItem)
|
||||
setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '')
|
||||
setLinkedPR(identity.type === 'pr' ? identity.number : null)
|
||||
setLinkedGitLabIssue(null)
|
||||
setLinkedGitLabMR(null)
|
||||
setLinkedWorkItem(linkedItem)
|
||||
@@ -2826,8 +2910,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// selected run host. Resolve git refs against the run repo; keep item
|
||||
// metadata/source context separate for provider identity.
|
||||
const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId)
|
||||
applyLinkedWorkItem(item)
|
||||
if (item.type !== 'pr' || !runRepo) {
|
||||
applyLinkedWorkItem(normalizedItem)
|
||||
if (identity.type !== 'pr' || !runRepo) {
|
||||
setBaseBranch(undefined)
|
||||
setCompareBaseRef(undefined)
|
||||
setPushTarget(undefined)
|
||||
@@ -2838,7 +2922,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
setPushTarget(undefined)
|
||||
const startPointSelection: SmartGitHubPrStartPointSelection = {
|
||||
repoId: runRepo.id,
|
||||
item
|
||||
item: normalizedItem
|
||||
}
|
||||
smartGitHubPrStartPointSelectionRef.current = startPointSelection
|
||||
const itemRepoSettings = getSettingsForRepoRuntimeOwner(
|
||||
@@ -2847,12 +2931,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
)
|
||||
const resolvePrBase = resolveGitHubPrStartPointForRepo({
|
||||
repoId: runRepo.id,
|
||||
prNumber: item.number,
|
||||
prNumber: identity.number,
|
||||
settings: itemRepoSettings,
|
||||
...(item.branchName ? { headRefName: item.branchName } : {}),
|
||||
...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
|
||||
...(item.isCrossRepository !== undefined
|
||||
? { isCrossRepository: item.isCrossRepository }
|
||||
...(normalizedItem.branchName ? { headRefName: normalizedItem.branchName } : {}),
|
||||
...(normalizedItem.baseRefName ? { baseRefName: normalizedItem.baseRefName } : {}),
|
||||
...(normalizedItem.isCrossRepository !== undefined
|
||||
? { isCrossRepository: normalizedItem.isCrossRepository }
|
||||
: {})
|
||||
})
|
||||
void resolvePrBase
|
||||
@@ -2863,7 +2947,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
startPointSelection.resolved = result
|
||||
handleBaseBranchPrSelect(
|
||||
result.baseBranch,
|
||||
item,
|
||||
normalizedItem,
|
||||
result.pushTarget,
|
||||
result.branchNameOverride,
|
||||
result.compareBaseRef
|
||||
|
||||
@@ -2452,7 +2452,6 @@
|
||||
"0a917b591a": "Paste",
|
||||
"f3eeb1de13": "Copy",
|
||||
"c2f0b72b8d": "Insert",
|
||||
"openLinkDefaultBrowser": "Open in Default Browser",
|
||||
"925f49f210": "Expand Pane",
|
||||
"df766809e0": "Collapse Pane",
|
||||
"cff67afad1": "Copy Context"
|
||||
@@ -4882,7 +4881,8 @@
|
||||
"d420c43729": "Imported {{value0}} cookies from {{value1}}{{value2}} into {{value3}}.",
|
||||
"b4c167764d": "Imported {{value0}} cookies from file into {{value1}}.",
|
||||
"a3f8c2d1e0b4": "Imported {{value0}} cookies from {{value1}} ({{value2}}) into {{value3}}.",
|
||||
"b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}."
|
||||
"b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}.",
|
||||
"c5a273a809": "From {{value0}}"
|
||||
},
|
||||
"BrowserUseComputerUseNotice": {
|
||||
"15b5e680ba": "Open Computer Use",
|
||||
@@ -4906,7 +4906,7 @@
|
||||
"be6df68384": "From File…",
|
||||
"e44c5d681e": "From",
|
||||
"67d9a53f47": "Manage profiles for separate logins",
|
||||
"112f70adc4": "Last imported from",
|
||||
"112f70adc4": "Last imported from {{value0}}",
|
||||
"72d4815523": "Bring your existing logins into Orca so agents can reach authenticated pages. Imports into the default profile.",
|
||||
"2eb906706c": "Import Browser Cookies",
|
||||
"af8c83ed61": "Import cookies from Chrome, Edge, or other browsers so agents can reuse your logins.",
|
||||
@@ -4928,7 +4928,8 @@
|
||||
"ad8cb0ee22": "Fix PATH",
|
||||
"0289434ed6": "Enabled",
|
||||
"8b3054dac7": "Registering...",
|
||||
"8f2675c2f3": "Imported {{value0}} cookies from file."
|
||||
"8f2675c2f3": "Imported {{value0}} cookies from file.",
|
||||
"5301857d88": "From {{value0}}"
|
||||
},
|
||||
"BrowserUseSkillStep": {
|
||||
"0871b6998d": "Enables agents to navigate and verify pages in Orca's browser.",
|
||||
@@ -9806,10 +9807,6 @@
|
||||
"a84d5677ff": "OpenCode",
|
||||
"2528586aa7": "Claudino"
|
||||
}
|
||||
},
|
||||
"PetOverlay": {
|
||||
"de932b0e8f": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }",
|
||||
"4712d196c6": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
|
||||
+3993
-3996
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user