Merge remote-tracking branch 'origin/main' into add-a-pet-for-orca

# Conflicts:
#	src/preload/index.ts
This commit is contained in:
Jinjing
2026-05-01 15:39:36 -07:00
84 changed files with 5139 additions and 711 deletions
+18
View File
@@ -245,3 +245,21 @@ jobs:
--draft=false \
--prerelease="$prerelease" \
--repo "$GITHUB_REPOSITORY"
# Why: dispatch the Homebrew cask bump directly instead of relying on a
# `release.published` trigger inside homebrew-bump.yml. GitHub blocks
# events raised by GITHUB_TOKEN (like the `gh release edit` above) from
# starting other workflows — it's a documented anti-loop safeguard. A
# workflow_dispatch from inside an already-running workflow is the
# documented escape hatch. Scoped to GA tags only; rc/prerelease tags
# should not touch the stable cask.
- name: Trigger Homebrew cask bump (stable releases only)
if: ${{ !contains(needs.resolve-release.outputs.tag, '-rc.') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.resolve-release.outputs.tag }}
run: |
gh workflow run homebrew-bump.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f tag="$TAG"
+11 -12
View File
@@ -1,8 +1,6 @@
<p align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
</p>
<h1 align="center">Orca</h1>
<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">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Supported Platforms" />
@@ -30,7 +28,7 @@
## Supported Agents
Orca supports any CLI agent (_not just this list_).
Orca supports any CLI agent (*not just this list*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
@@ -49,7 +47,7 @@ Orca supports any CLI agent (_not just this list_).
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<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" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<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.png" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<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" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
@@ -73,9 +71,12 @@ Orca supports any CLI agent (_not just this list_).
## Install
- **[Download from onOrca.dev](https://onOrca.dev)** — or grab the latest binaries from the **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**.
### Mac, Linux, Windows
Alternatively, install from a package manager:
- **[Download from onOrca.dev](https://onOrca.dev)**
- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
*Alternatively, install from a package manager:*
### macOS (Homebrew)
@@ -93,8 +94,6 @@ yay -S stably-orca-bin
yay -S stably-orca-git
```
See [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) and [stably-orca-git](https://aur.archlinux.org/packages/stably-orca-git) on the AUR.
---
## [New] Annotate AI Diff
@@ -156,4 +155,4 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
## Developing
Want to contribute or run locally? See our [CONTRIBUTING.md](CONTRIBUTING.md) guide.
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
+22 -23
View File
@@ -1,13 +1,11 @@
<p align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
</p>
<h1 align="center">Orca</h1>
<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">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Supported Platforms" />
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Plataformas compatibles" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Seguir en X" /></a>
</p>
<p align="center">
@@ -16,7 +14,7 @@
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Corre Claude Code, Codex u OpenCode en paralelo a lo largo de varios repos — cada uno en su propio worktree, todos administrados desde un solo lugar.<br/>
Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
</p>
@@ -25,12 +23,12 @@
</p>
<p align="center">
<img src="docs/assets/file-drag.gif" alt="Captura de Orca" width="800" />
<img src="assets/file-drag.gif" alt="Captura de Orca" width="800" />
</p>
## Agentes compatibles
Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
@@ -49,7 +47,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<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" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<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.png" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<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" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
@@ -63,19 +61,22 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
- **Sin login** — Usa tu propia suscripción de Claude Code o Codex.
- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
- **Terminales multi-agente** — Corre varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
- **Soporte SSH** — Conéctate a máquinas remotas y corre agentes en ellas directamente desde Orca.
---
## Instalación
- **[Descarga desde onOrca.dev](https://onOrca.dev)** — o descarga los binarios más recientes desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**.
### Mac, Linux, Windows
También puedes instalar desde un gestor de paquetes:
- **[Descarga desde onOrca.dev](https://onOrca.dev)**
- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
*También puedes instalar desde un gestor de paquetes:*
### macOS (Homebrew)
@@ -93,8 +94,6 @@ yay -S stably-orca-bin
yay -S stably-orca-git
```
Consulta [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) y [stably-orca-git](https://aur.archlinux.org/packages/stably-orca-git) en el AUR.
---
## [Nuevo] Anotar diff de IA
@@ -104,7 +103,7 @@ Consulta [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) y
Anota cualquier línea de un diff generado por IA con tus comentarios y mándalo de vuelta al agente para que lo corrija. Mantén el ciclo de revisión bien ajustado — sin copiar números de línea, sin cambiar de contexto.
<p align="center">
<img src="docs/assets/annotate-ai-diff.gif" alt="Orca Anotar diff de IA — comenta en diffs generados por IA y envía feedback al agente" width="800" />
<img src="assets/annotate-ai-diff.gif" alt="Orca Anotar diff de IA — comenta en diffs generados por IA y envía feedback al agente" width="800" />
</p>
---
@@ -116,19 +115,19 @@ Anota cualquier línea de un diff generado por IA con tus comentarios y mándalo
Si usas varias cuentas de Codex para aprovechar el mejor precio en tokens, Orca te permite cambiar entre ellas al instante — sin volver a hacer login, sin tocar archivos de configuración. Elige una cuenta y sigue construyendo.
<p align="center">
<img src="docs/assets/codex-account-switcher.gif" alt="Cambio de cuentas de Codex en Orca — alterna entre múltiples cuentas de Codex" width="800" />
<img src="assets/codex-account-switcher.gif" alt="Cambio de cuentas de Codex en Orca — alterna entre múltiples cuentas de Codex" width="800" />
</p>
---
## [Nuevo] Navegador y modo diseño por worktree
## [Nuevo] Navegador por worktree y modo diseño
**Mira tu app. Haz clic en cualquier elemento. Arrástralo al chat.**
**Mira tu app. Haz clic en cualquier elemento. Suéltalo en el chat.**
Orca trae un navegador integrado dentro de tu worktree. Previsualiza tu app mientras la construyes, y cuando quieras cambia al modo diseño — haz clic en cualquier elemento de UI y cae directo en tu chat con la IA como contexto. Sin capturas, sin copiar selectores. Solo apunta a lo que quieres cambiar y dile al agente qué hacer.
<p align="center">
<img src="docs/assets/orca-design-mode.gif" alt="Modo diseño de Orca — haz clic en cualquier elemento de UI y suéltalo en el chat" width="800" />
<img src="assets/orca-design-mode.gif" alt="Modo diseño de Orca — haz clic en cualquier elemento de UI y suéltalo en el chat" width="800" />
</p>
---
@@ -156,4 +155,4 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
## Desarrollo
¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](CONTRIBUTING.md).
¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../.github/CONTRIBUTING.md).
+42 -43
View File
@@ -1,14 +1,11 @@
<p align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
</p>
<h1 align="center">Orca</h1>
<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://img.shields.io/github/stars/stablyai/orca?style=for-the-badge&color=black" alt="GitHub stars" /></a>
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-black?style=for-the-badge" alt="Supported Platforms" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-black?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="対応プラットフォーム" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X でフォロー" /></a>
</p>
<p align="center">
@@ -16,9 +13,9 @@
</p>
<p align="center">
<strong>100x 開発者のための AI IDE。</strong><br/>
Claude Code、Codex、または OpenCode をリポジトリをまたいで並行して実行 — 各エージェントは独立したワークツリーで実行され、1 つの場所で集中管理されます。<br/>
<strong>macOS、Windows、Linux</strong> で利用可能です。
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、Codex、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
<strong>macOS、Windows、Linux</strong> で利用できます。
</p>
<p align="center">
@@ -26,12 +23,12 @@
</p>
<p align="center">
<img src="docs/assets/file-drag.gif" alt="Orca Screenshot" width="800" />
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 対応する AI Agent
## 対応するエージェント
Orca は任意の CLI Agent に対応しています(_このリストに限定されません_)。
Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
@@ -50,7 +47,7 @@ Orca は任意の CLI Agent に対応しています(_このリストに限定
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<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" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<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.png" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<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" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
@@ -63,20 +60,23 @@ Orca は任意の CLI Agent に対応しています(_このリストに限定
## 機能
- **ログイン不要** — お持ちの Claude Code や Codex サブスクリプションをそのまま利用できます。
- **ワークツリーネイティブ** — 各機能は独立したワークツリーで開発。スタッシュやブランチの切り替えに悩む必要はありません。瞬時に立ち上げ、切り替えることができます。
- **マルチAgentターミナル** — 複数の AI Agent をタブやペインで並行して実行。どのアカウントがアクティブかを一目で確認できます。
- **内蔵バージョン管理** — AI が生成したコードDiffを確認し、素早く編集を行い、Orca から離れることなく直接コミットできます。
- **GitHub 連携** — PR、Issue、Actions のチェックは、各ワークツリーに自動的にリンクされます。
- **通知** — エージェントが作業を完了した際や注意が必要な際にお知らせします。スレッドを未読にして後で確認することも可能です。
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
---
## インストール
- **[onOrca.dev からダウンロード](https://onOrca.dev)** — または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から最新のインストーラーを取得できます。
### Mac, Linux, Windows
パッケージマネージャーからのインストールも可能です:
- **[onOrca.dev からダウンロード](https://onOrca.dev)**
- または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から入手
*パッケージマネージャーからもインストールできます:*
### macOS (Homebrew)
@@ -94,41 +94,40 @@ yay -S stably-orca-bin
yay -S stably-orca-git
```
AUR の [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) と [stably-orca-git](https://aur.archlinux.org/packages/stably-orca-git) を参照してください。
---
## [新機能] AI Diff へのコメント
## [新機能] AI Diff への注釈
**AI が生成した Diff に直接コメントを追加。**
**AI が生成した Diff に直接コメントできます。**
AI が生成した Diff の任意の行にフィードバックを付け、そのままエージェントに返して修正させましょう。レビューのループを素早く回せます — 行番号をコピーする必要も、コンテキストを切り替える必要もありません。
AI が生成した Diff の任意の行にフィードバックを付け、そのままエージェントに返して修正させましょう。レビューのループを素早く保てます — 行番号をコピーする必要も、コンテキストを切り替える必要もありません。
<p align="center">
<img src="docs/assets/annotate-ai-diff.gif" alt="Orca AI Diff コメント — AI が生成した Diff にコメントしてエージェントにフィードバックを送信" width="800" />
<img src="assets/annotate-ai-diff.gif" alt="Orca AI Diff 注釈 — AI が生成した Diff にコメントしてエージェントにフィードバックを送信" width="800" />
</p>
---
## [新機能] Codex アカウントのホットスワップ
**複数の Codex アカウントをお持ちですか?ワンクリックで切り替え可能です。**
**複数の Codex アカウントをお持ちですか?ワンクリックで切り替えできます。**
最適な API クォータを得るために複数の Codex アカウントを使用している場合、Orca では再ログインや設定ファイルなしで、即座にアカウントをホットスワップできます。ワンクリックでアカウントを切り替え可能です。
最適なトークン条件を得るために複数の Codex アカウントを使っている場合、Orca なら再ログインも設定ファイルの編集も不要で、すぐにアカウントをホットスワップできます。アカウントを選んで、そのまま開発を続けられます。
<p align="center">
<img src="docs/assets/codex-account-switcher.gif" alt="Orca Codex アカウント切り替え — 複数のアカウント間でホットスワップ" width="800" />
<img src="assets/codex-account-switcher.gif" alt="Orca Codex アカウント切り替え — 複数の Codex アカウント間でホットスワップ" width="800" />
</p>
---
## [新機能] ワークツリーごとのブラウザ & デザインモード
## [新機能] ワークツリーごとのブラウザとデザインモード
**アプリをプレビュー。要素をクリックして。そのままチャットにドロップ。**
**アプリを表示。任意の要素をクリック。チャットにそのまま投入。**
Orca には、ワークツリー内で使える組み込みブラウザがあります。アプリを作りながらプレビューし、デザインモードに切り替えると、任意の UI 要素をクリックするだけで AI チャットにコンテキストとして直接入ります。スクリーンショットもセレクターのコピーも不要です。変更したい箇所を指して、エージェントに指示するだけです。
Orca には、ワークツリー内にブラウザが組み込まれています。いつでもアプリをプレビューし、デザインモードに切り替えます — 任意の UI 要素をクリックすると、コンテキストとしてそのまま AI チャットに取り込まれます。
<p align="center">
<img src="docs/assets/orca-design-mode.gif" alt="Orca デザインモード — UI 要素をクリックしてチャットにドロップ" width="800" />
<img src="assets/orca-design-mode.gif" alt="Orca デザインモード — 任意の UI 要素をクリックしてチャットに投入" width="800" />
</p>
---
@@ -137,7 +136,7 @@ Orca には、ワークツリー内にブラウザが組み込まれています
**ターミナルからのエージェントオーケストレーション。**
AI Agent に Orca IDE を制御させましょう。AI を使って IDE にプロジェクトを追加したり、ワークツリーを立ち上げたり、ターミナルから AI Agent に現在のワークツリーのコメントを直接更新させることができます。Orca IDE に同梱されています(設定からインストール)。
AI エージェントに IDE を制御させましょう。AI を使って IDE にプロジェクトを追加し、ワークツリーを立ち上げ、現在のワークツリーのコメントを進捗チェックポイントとしてターミナルから直接更新できます。Orca IDE に同梱されています(設定からインストール)。
```bash
npx skills add https://github.com/stablyai/orca --skill orca-cli
@@ -145,15 +144,15 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
---
## コミュニティ & サポート
## コミュニティとサポート
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加する。
- **Twitter / X:** アップデートやアナウンスについては **[@orca_build](https://x.com/orca_build)** をフォローしてください。
- **フィードバック & アイデア:** 私たちは迅速に新機能をリリースします。不足している機能はありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
- **サポートを示す:** 毎日のリリースを追うために、このリポジトリにスターをつけてください。
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加してください。
- **Twitter / X:** アップデートやお知らせは **[@orca_build](https://x.com/orca_build)** をフォローしてください。
- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
- **応援する:** 毎日のリリースを追うために、このリポジトリにスターを付けてください。
---
## 開発について
貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](CONTRIBUTING.md) ガイドをご覧ください。
貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) ガイドをご覧ください。
+41 -42
View File
@@ -1,14 +1,11 @@
<p align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
</p>
<h1 align="center">Orca</h1>
<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://img.shields.io/github/stars/stablyai/orca?style=for-the-badge&color=black" alt="GitHub stars" /></a>
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-black?style=for-the-badge" alt="Supported Platforms" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-black?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="支持的平台" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="在 X 上关注" /></a>
</p>
<p align="center">
@@ -16,8 +13,8 @@
</p>
<p align="center">
<strong>专为 100x 开发者打造的 AI IDE。</strong><br/>
跨代码库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己独立的工作区中运行,并在一个地方集中管理。<br/>
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
跨仓库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
支持 <strong>macOS、Windows 和 Linux</strong>。
</p>
@@ -26,12 +23,12 @@
</p>
<p align="center">
<img src="docs/assets/file-drag.gif" alt="Orca Screenshot" width="800" />
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 支持的 AI Agents
## 支持的智能体
Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
@@ -50,7 +47,7 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<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" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<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.png" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<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" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
@@ -62,21 +59,24 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
## 特性
- **免登录** — 直接用你的 Claude Code 或 Codex 订阅。
- **原生支持 Worktree** — 每个功能都在自己的工作树中开发。告别频繁的 stashing 或切换分支。瞬间启动并无缝切换。
- **多Agent终端** — 在不同标签和面板中并排运行多个 AI Agent。一目了然地查看哪些代理处于活跃状态。
- **内置版本控制** — 查看 AI 生成的代码Diff,进行快速修改并直接提交,无需离开 Orca。
- **集成 GitHub** — PR、Issues 和 Actions 检查会自动链接到对应的工作区。
- **通知系统** — 当代理完成任务或需要关注时及时通知你。支持标记为未读稍后处理。
- **SSH 支持** — 连接远程机器,直接在 Orca 中运行远程 Agent。
- **无需登录** — 直接使用你自己的 Claude Code 或 Codex 订阅。
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
---
## 安装
- **[从 onOrca.dev 下载](https://onOrca.dev)** — 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取最新安装包。
### Mac, Linux, Windows
你也可以通过包管理器安装:
- **[从 onOrca.dev 下载](https://onOrca.dev)**
- 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取
*也可以通过包管理器安装:*
### macOS (Homebrew)
@@ -94,18 +94,16 @@ yay -S stably-orca-bin
yay -S stably-orca-git
```
详见 AUR 上的 [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) 与 [stably-orca-git](https://aur.archlinux.org/packages/stably-orca-git)。
---
## [全新] AI Diff 标注
## [全新] 标注 AI Diff
**直接在 AI 生成的 Diff 上添加批注。**
**直接在 AI 生成的 diff 上评论。**
在 AI 生成的 Diff 的任意一行添加你的反馈,然后发回给 Agent 进行修订。保持评审循环高效顺畅 — 无需复制行号,也无需切换上下文。
在 AI 生成的 diff 的任意一行添加你的反馈,然后发回给智能体进行修改。让评审循环保持紧凑 — 无需复制行号,也无需切换上下文。
<p align="center">
<img src="docs/assets/annotate-ai-diff.gif" alt="Orca AI Diff 标注 — 在 AI 生成的 Diff 上评论并将反馈发送给 Agent" width="800" />
<img src="assets/annotate-ai-diff.gif" alt="Orca 标注 AI Diff — 在 AI 生成的 diff 上评论并将反馈发送给智能体" width="800" />
</p>
---
@@ -114,30 +112,31 @@ yay -S stably-orca-git
**有多个 Codex 账号?一键切换。**
如果你使用多个 Codex 账号以获得最佳 API 额度,Orca 让你能在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。只需一键点击切换账号。
如果你使用多个 Codex 账号来获得更合适的 token 配额,Orca 可让你在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。选中账号,然后继续构建。
<p align="center">
<img src="docs/assets/codex-account-switcher.gif" alt="Orca Codex 账号切换器 — 在多个账号之间热切换" width="800" />
<img src="assets/codex-account-switcher.gif" alt="Orca Codex 账号切换器 — 在多个 Codex 账号之间热切换" width="800" />
</p>
---
## [全新] 每工作树内置浏览器与设计模式
## [全新] 每个 worktree 的浏览器和设计模式
**预览应用。点击任意元素。直接拖入聊天框。**
**查看你的应用。点击任意元素。直接放入聊天。**
Orca 在你的 worktree 中内置了浏览器。边构建边预览应用,然后切换到设计模式 — 点击任意 UI 元素,它会作为上下文直接进入你的 AI 聊天。无需截图,无需复制选择器。只需指向想修改的地方,然后告诉智能体要做什么。
Orca 在你的工作树中内置了浏览器。随时预览你的应用,然后切换到设计模式 — 点击任何 UI 元素,它就会直接落入你的 AI 聊天框作为上下文。
<p align="center">
<img src="docs/assets/orca-design-mode.gif" alt="Orca 设计模式 — 点击任意 UI 元素并放入聊天框" width="800" />
<img src="assets/orca-design-mode.gif" alt="Orca 设计模式 — 点击任意 UI 元素并放入聊天" width="800" />
</p>
---
## [全新] 隆重推出 Orca CLI
## [全新] Orca CLI 介绍
**在终端中进行代理编排。**
**从终端编排智能体。**
让 AI Agent控制你的Orca IDE。使用 AI 将项目添加到 IDE、启动工作树,并在终端让AI Agent直接更新当前工作树的备注。随 Orca IDE 附带 (在设置中安装)。
让你的 AI 智能体控制 IDE。使用 AI 将项目添加到 IDE、创建 worktree,并直接从终端更新当前 worktree 的评论,留下有意义的进度检查点。Orca IDE 随附此功能(在设置中安装)。
```bash
npx skills add https://github.com/stablyai/orca --skill orca-cli
@@ -149,11 +148,11 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
- **Discord:** 加入我们的 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **反馈与想法:** 我们发布新功能非常快。需要新功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **支持我们:** 给本仓库点亮 Star,关注我们的日常更新。
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **支持我们:** 给这个仓库点 Star,关注我们的日常发布。
---
## 开发
想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](CONTRIBUTING.md) 指南。
想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 指南。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.26-rc.0",
"version": "1.3.26-rc.1",
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+224 -17
View File
@@ -1,17 +1,38 @@
/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner
mock harness, and splitting them would duplicate setup without a clearer boundary. */
import type * as FsPromises from 'fs/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({
const {
gitExecFileAsyncMock,
gitExecFileSyncMock,
translateWslOutputPathsMock,
statMock,
resolveGitDirMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn()
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
statMock: vi.fn(),
resolveGitDirMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
translateWslOutputPaths: (output: string) => output
translateWslOutputPaths: translateWslOutputPathsMock
}))
import { removeWorktree } from './worktree'
vi.mock('./status', () => ({
resolveGitDir: resolveGitDirMock
}))
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return { ...actual, stat: statMock }
})
import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree'
type MockResult = {
error?: Error
@@ -54,11 +75,19 @@ describe('removeWorktree', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockReset()
translateWslOutputPathsMock.mockImplementation((output: string) => output)
statMock.mockReset()
// Default: no worktree has a sparse-checkout config file. Tests that need
// sparse detection override this.
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
resolveGitDirMock.mockReset()
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('removes the worktree, prunes stale refs, and deletes its local branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -68,7 +97,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -92,7 +121,7 @@ branch refs/heads/main
it('skips branch deletion when another worktree still points at the branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -106,7 +135,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -125,7 +154,7 @@ branch refs/heads/feature/test
expect.arrayContaining([
'git worktree remove /repo-feature',
'git worktree prune',
'git worktree list --porcelain'
'git worktree list --porcelain -z'
])
)
expect(calls).not.toContain('git branch -D feature/test')
@@ -134,7 +163,7 @@ branch refs/heads/feature/test
it('deletes the branch after prune removes stale sibling worktree entries', async () => {
mockGitCommands({
'git worktree list --porcelain': {
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -149,7 +178,7 @@ branch refs/heads/feature/test
prunable gitdir file points to non-existent location
`
},
'git worktree list --porcelain#2': {
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -172,7 +201,7 @@ branch refs/heads/main
it('passes --force before the worktree path when forced removal is requested', async () => {
mockGitCommands({
'git worktree list --porcelain': {
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -182,7 +211,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -197,7 +226,7 @@ branch refs/heads/main
it('matches Windows worktree paths before deleting the branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
'git worktree list --porcelain -z': {
stdout: `worktree C:/repo
HEAD abc123
branch refs/heads/main
@@ -207,7 +236,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
'git worktree list --porcelain -z#2': {
stdout: `worktree C:/repo
HEAD abc123
branch refs/heads/main
@@ -230,7 +259,7 @@ branch refs/heads/main
it('keeps removal successful when branch cleanup fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGitCommands({
'git worktree list --porcelain': {
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -240,7 +269,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain#2': {
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -262,3 +291,181 @@ branch refs/heads/main
warnSpy.mockRestore()
})
})
describe('listWorktrees', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockReset()
translateWslOutputPathsMock.mockImplementation((output: string) => output)
statMock.mockReset()
// Default: no worktree has a sparse-checkout config file. Tests that need
// sparse detection override this.
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
resolveGitDirMock.mockReset()
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('translates parsed path fields from NUL-delimited porcelain output', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0'
})
translateWslOutputPathsMock.mockImplementation((output: string) => {
expect(output).not.toContain('\0')
return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
})
await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isSparse: true,
isMainWorktree: false
}
])
// Why: the non-sparse main worktree gets an fs probe of its sparse config
// file; the linked worktree short-circuits on the parsed `sparse` token and
// does not. Only one git subprocess runs regardless of worktree count.
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
expect(statMock).toHaveBeenCalledTimes(1)
expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2)
})
it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.join(' ') === 'worktree list --porcelain -z') {
return {
stdout:
'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0',
stderr: ''
}
}
throw new Error(`Unexpected git call: ${args.join(' ')}`)
})
translateWslOutputPathsMock.mockImplementation((output: string) => {
expect(output).not.toContain('\0')
return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
})
const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature'
resolveGitDirMock.mockImplementation(async (worktreePath: string) =>
worktreePath === featureWorktreePath
? `${featureWorktreePath}\\.git-worktrees\\feature`
: `${worktreePath}/.git`
)
statMock.mockImplementation(async (filePath: string) => {
if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) {
return { isFile: () => true, size: 32 }
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')
expect(worktrees).toEqual([
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
head: 'def456',
branch: 'refs/heads/feature/test',
isBare: false,
isSparse: true,
isMainWorktree: false
}
])
expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath)
// Why: the detection path must not spawn a git subprocess per worktree —
// the perf regression in #1131 came from `git sparse-checkout list` firing
// on every poll.
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
})
})
describe('addSparseWorktree', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockReset()
translateWslOutputPathsMock.mockImplementation((output: string) => output)
statMock.mockReset()
// Default: no worktree has a sparse-checkout config file. Tests that need
// sparse detection override this.
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
resolveGitDirMock.mockReset()
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('separates sparse checkout directory operands from options', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src'])
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['sparse-checkout', 'set', '--', '-docs', 'src'],
{ cwd: '/repo-feature' }
)
})
it('removes the worktree and deletes the created branch when sparse setup fails', async () => {
mockGitCommands({
'git sparse-checkout set -- packages/web': {
error: new Error('sparse setup failed')
},
'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
`
},
'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
}
})
await expect(
addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
).rejects.toThrow('sparse setup failed')
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git worktree add --no-checkout -b feature/test /repo-feature',
'git sparse-checkout init --cone',
'git sparse-checkout set -- packages/web',
'git worktree remove --force /repo-feature',
'git worktree prune',
'git branch -D feature/test'
])
)
expectGitCallOrder(
calls,
'git sparse-checkout set -- packages/web',
'git worktree remove --force /repo-feature'
)
expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test')
})
})
+1 -1
View File
@@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise<Git
return 'unknown'
}
async function resolveGitDir(worktreePath: string): Promise<string> {
export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
try {
+135 -17
View File
@@ -1,6 +1,12 @@
import { posix, win32 } from 'path'
import { stat } from 'fs/promises'
import { join, posix, win32 } from 'path'
import type { GitWorktreeInfo } from '../../shared/types'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
type SparseWorktreeCreateError = Error & {
cleanupFailed?: boolean
}
function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
@@ -29,21 +35,23 @@ function looksLikeWindowsPath(pathValue: string): boolean {
*/
export function parseWorktreeList(output: string): GitWorktreeInfo[] {
const worktrees: GitWorktreeInfo[] = []
// [Fix]: Use /\r?\n\r?\n/ to handle both LF and CRLF (\r\n) line endings,
// which are common when running git on Windows.
const blocks = output.trim().split(/\r?\n\r?\n/)
const blocks = output.includes('\0')
? parseNullDelimitedWorktreeBlocks(output)
: output
.trim()
.split(/\r?\n\r?\n/)
.map((block) => block.trim().split(/\r?\n/))
for (const block of blocks) {
if (!block.trim()) {
for (const lines of blocks) {
if (lines.length === 0) {
continue
}
// [Fix]: Use /\r?\n/ to handle both LF and CRLF (\r\n) line endings.
const lines = block.trim().split(/\r?\n/)
let path = ''
let head = ''
let branch = ''
let isBare = false
let isSparse = false
for (const line of lines) {
if (line.startsWith('worktree ')) {
@@ -54,12 +62,21 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
} else if (line === 'sparse') {
isSparse = true
}
}
if (path) {
// `git worktree list` always emits the main working tree first.
worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 })
worktrees.push({
path,
head,
branch,
isBare,
...(isSparse ? { isSparse } : {}),
isMainWorktree: worktrees.length === 0
})
}
}
@@ -71,14 +88,24 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
*/
export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
try {
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: repoPath
})
// Why: when git runs inside WSL, worktree paths are Linux-native
// (e.g. /home/user/repo). Translate them back to Windows UNC paths
// so the rest of Orca can access them via Node fs APIs.
const translated = translateWslOutputPaths(stdout, repoPath)
return parseWorktreeList(translated)
// Why: WSL path translation is line-oriented, but `-z` porcelain output is
// NUL-delimited. Parse first so only complete path fields are translated.
const worktrees = parseWorktreeList(stdout).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
return Promise.all(
worktrees.map(async (worktree) => {
if (worktree.isBare || worktree.isSparse) {
return worktree
}
const isSparse = await detectSparseCheckout(worktree.path)
return isSparse ? { ...worktree, isSparse } : worktree
})
)
} catch {
return []
}
@@ -96,7 +123,8 @@ export async function addWorktree(
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false
refreshLocalBaseRef = false,
noCheckout = false
): Promise<void> {
// Why: Some users want Orca-created worktrees to make plain commands like
// `git diff main...HEAD` work out of the box, while others do not want
@@ -152,13 +180,49 @@ export async function addWorktree(
}
}
const args = ['worktree', 'add', '-b', branch, worktreePath]
const args = ['worktree', 'add']
if (noCheckout) {
args.push('--no-checkout')
}
args.push('-b', branch, worktreePath)
if (baseBranch) {
args.push(baseBranch)
}
await gitExecFileAsync(args, { cwd: repoPath })
}
export async function addSparseWorktree(
repoPath: string,
worktreePath: string,
branch: string,
directories: string[],
baseBranch?: string,
refreshLocalBaseRef = false
): Promise<void> {
let created = false
try {
await addWorktree(repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, true)
created = true
await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath })
await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath })
await gitExecFileAsync(['checkout', branch], { cwd: worktreePath })
} catch (error) {
const wrapped: SparseWorktreeCreateError =
error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error))
if (created) {
try {
await removeWorktree(repoPath, worktreePath, true)
} catch {
wrapped.cleanupFailed = true
// Why: the user needs to know that manual cleanup may be required —
// otherwise a half-created worktree silently lingers on disk.
wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)`
}
}
throw wrapped
}
}
/**
* Remove a worktree.
*/
@@ -208,3 +272,57 @@ export async function removeWorktree(
)
}
}
function parseNullDelimitedWorktreeBlocks(output: string): string[][] {
const blocks: string[][] = []
let current: string[] = []
for (const token of output.split('\0')) {
if (!token) {
if (current.length > 0) {
blocks.push(current)
current = []
}
continue
}
current.push(token)
}
if (current.length > 0) {
blocks.push(current)
}
return blocks
}
function translateWorktreePath(worktreePath: string, repoPath: string): string {
const prefix = 'worktree '
const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath)
return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath
}
async function detectSparseCheckout(worktreePath: string): Promise<boolean> {
// Why: `listWorktrees` runs on every 3-second git-status poll and on every
// worktree refresh, so this probe fires N times per poll for N worktrees.
// The previous `git sparse-checkout list` subprocess made that N*poll extra
// git processes, which regressed app responsiveness on machines with many
// worktrees (see PR #1131 revert in #1290). A single fs.stat on the
// per-worktree sparse-checkout config file is ~two orders of magnitude
// cheaper and has the same truthiness semantics: Git writes this file when
// sparse checkout is enabled for the worktree and does not write it
// otherwise.
//
// Why per-worktree gitdir and not `<worktreePath>/.git/info/sparse-checkout`:
// linked worktrees have a `.git` file that points at
// `<repo>/.git/worktrees/<name>`, and that is where Git stores the
// worktree-local sparse-checkout config. `core.sparseCheckout` itself is
// shared across all worktrees, so the presence of the config file is the
// correct per-worktree signal.
try {
const gitDir = await resolveGitDir(worktreePath)
const stats = await stat(join(gitDir, 'info', 'sparse-checkout'))
return stats.isFile() && stats.size > 0
} catch {
return false
}
}
+150
View File
@@ -96,6 +96,27 @@ const REPO_PATH = path.resolve('/workspace/repo')
const WORKSPACE_DIR = path.resolve('/workspace')
const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature')
type MockDirEntry = {
name: string
directory?: boolean
file?: boolean
symlink?: boolean
}
function dirEntry({ name, directory, file, symlink }: MockDirEntry): {
name: string
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
} {
return {
name,
isDirectory: () => directory ?? false,
isFile: () => file ?? false,
isSymbolicLink: () => symlink ?? false
}
}
describe('registerFilesystemHandlers', () => {
const store = {
getRepos: () => [
@@ -311,6 +332,135 @@ describe('registerFilesystemHandlers', () => {
expect(bulkUnstageFilesMock).not.toHaveBeenCalled()
})
it('lists markdown documents recursively for a registered worktree', async () => {
readdirMock.mockImplementation(async (dirPath: string) => {
if (dirPath === WORKTREE_FEATURE_PATH) {
return [
dirEntry({ name: 'README.md', file: true }),
dirEntry({ name: 'docs', directory: true }),
dirEntry({ name: 'script.ts', file: true })
]
}
if (dirPath === path.join(WORKTREE_FEATURE_PATH, 'docs')) {
return [
dirEntry({ name: 'Guide.MDX', file: true }),
dirEntry({ name: 'notes.markdown', file: true })
]
}
return []
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual([
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'Guide.MDX'),
relativePath: 'docs/Guide.MDX',
basename: 'Guide.MDX',
name: 'Guide'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'notes.markdown'),
relativePath: 'docs/notes.markdown',
basename: 'notes.markdown',
name: 'notes'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md'),
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
})
it('skips ignored and symlinked directories when listing markdown documents', async () => {
readdirMock.mockImplementation(async (dirPath: string) => {
if (dirPath === WORKTREE_FEATURE_PATH) {
return [
dirEntry({ name: '.git', directory: true }),
dirEntry({ name: '.hidden', directory: true }),
dirEntry({ name: '.github', directory: true }),
dirEntry({ name: 'node_modules', directory: true }),
dirEntry({ name: 'linked-docs', directory: true, symlink: true }),
dirEntry({ name: 'visible.md', file: true })
]
}
if (dirPath === path.join(WORKTREE_FEATURE_PATH, '.github')) {
return [dirEntry({ name: 'CONTRIBUTING.md', file: true })]
}
throw new Error(`Unexpected readdir: ${dirPath}`)
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual([
{
filePath: path.join(WORKTREE_FEATURE_PATH, '.github', 'CONTRIBUTING.md'),
relativePath: '.github/CONTRIBUTING.md',
basename: 'CONTRIBUTING.md',
name: 'CONTRIBUTING'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'visible.md'),
relativePath: 'visible.md',
basename: 'visible.md',
name: 'visible'
}
])
})
it('rejects markdown document listing for authorized but unregistered roots', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: path.resolve('/workspace/unregistered')
})
).rejects.toThrow('Access denied: unknown repository or worktree path')
expect(readdirMock).not.toHaveBeenCalled()
})
it('lists remote markdown documents through the SSH filesystem provider', async () => {
const provider = {
listFiles: vi
.fn()
.mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts'])
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: '/home/user/project',
connectionId: 'ssh-1'
})
).resolves.toEqual([
{
filePath: '/home/user/project/docs/guide.mdx',
relativePath: 'docs/guide.mdx',
basename: 'guide.mdx',
name: 'guide'
},
{
filePath: '/home/user/project/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
})
it('routes branch compare queries through the git compare helper', async () => {
getBranchCompareMock.mockResolvedValue({
summary: {
+22
View File
@@ -12,6 +12,7 @@ import type {
GitConflictOperation,
GitDiffResult,
GitStatusResult,
MarkdownDocument,
SearchOptions,
SearchResult
} from '../../shared/types'
@@ -47,6 +48,7 @@ import {
import { listQuickOpenFiles } from './filesystem-list-files'
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import { searchWithGitGrep } from './filesystem-search-git'
import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents'
import { checkRgAvailable } from './rg-availability'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
@@ -161,6 +163,26 @@ export function registerFilesystemHandlers(store: Store): void {
}
)
ipcMain.handle(
'fs:listMarkdownDocuments',
async (
_event,
args: { rootPath: string; connectionId?: string }
): Promise<MarkdownDocument[]> => {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
if (!provider) {
throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
}
const relativePaths = await provider.listFiles(args.rootPath)
return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths)
}
const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store)
return listMarkdownDocuments(rootPath)
}
)
ipcMain.handle(
'fs:writeFile',
async (
+98
View File
@@ -0,0 +1,98 @@
import { readdir } from 'fs/promises'
import { basename as pathBasename, extname, join, relative } from 'path'
import type { MarkdownDocument } from '../../shared/types'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
}
function basenameFromRelativePath(relativePath: string): string {
const normalizedPath = relativePath.replaceAll('\\', '/')
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
}
function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
function toMarkdownDocument(rootPath: string, filePath: string): MarkdownDocument {
const basename = pathBasename(filePath)
const extension = extname(basename)
return {
filePath,
relativePath: normalizeRelativePath(relative(rootPath, filePath)),
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
}
export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
const normalizedRelativePath = normalizeRelativePath(relativePath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
if (!isSafeRelativePath(normalizedRelativePath)) {
return null
}
const basename = basenameFromRelativePath(normalizedRelativePath)
if (!isMarkdownDocumentName(basename)) {
return null
}
const extension = extname(basename)
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
return {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
}
export function markdownDocumentsFromRelativePaths(
rootPath: string,
relativePaths: string[]
): MarkdownDocument[] {
return relativePaths
.map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath))
.filter((document): document is MarkdownDocument => document !== null)
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
export async function listMarkdownDocuments(rootPath: string): Promise<MarkdownDocument[]> {
const documents: MarkdownDocument[] = []
async function visitDirectory(dirPath: string): Promise<void> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue
}
const entryPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
if (entry.name === '.git' || entry.name === 'node_modules') {
continue
}
if (entry.name.startsWith('.') && entry.name !== '.github') {
continue
}
await visitDirectory(entryPath)
continue
}
if (entry.isFile() && isMarkdownDocumentName(entry.name)) {
documents.push(toMarkdownDocument(rootPath, entryPath))
}
}
}
await visitDirectory(rootPath)
return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
+223
View File
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as CryptoModule from 'crypto'
import type { SparsePreset } from '../../shared/types'
const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({
handleMock: vi.fn(),
randomUUIDMock: vi.fn(() => 'preset-new'),
mockStore: {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeRepo: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn(),
getSparsePresets: vi.fn(),
saveSparsePreset: vi.fn(),
removeSparsePreset: vi.fn()
}
}))
vi.mock('crypto', async (importOriginal) => {
const actual = await importOriginal<typeof CryptoModule>()
return {
...actual,
randomUUID: randomUUIDMock
}
})
vi.mock('electron', () => ({
dialog: { showOpenDialog: vi.fn() },
ipcMain: {
handle: handleMock,
removeHandler: vi.fn()
}
}))
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn().mockReturnValue(true),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
searchBaseRefs: vi.fn().mockResolvedValue([]),
BASE_REF_SEARCH_ARGS: ['for-each-ref'],
filterBaseRefSearchOutput: vi.fn().mockReturnValue([])
}))
vi.mock('./filesystem-auth', () => ({
rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn()
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: vi.fn()
}))
import { registerRepoHandlers } from './repos'
type HandlerMap = Map<string, (_event: unknown, args: unknown) => unknown>
function makePreset(
overrides: Partial<SparsePreset> & { id: string; repoId: string }
): SparsePreset {
return {
name: overrides.id,
directories: ['packages/web'],
createdAt: 10,
updatedAt: 20,
...overrides
}
}
describe('sparse preset repo IPC handlers', () => {
const handlers: HandlerMap = new Map()
const mainWindow = {
isDestroyed: () => false,
webContents: { send: vi.fn() }
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
randomUUIDMock.mockReturnValue('preset-new')
mainWindow.webContents.send.mockReset()
mockStore.getRepo.mockReset().mockReturnValue({
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
})
mockStore.getSparsePresets.mockReset().mockReturnValue([])
mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset)
mockStore.removeSparsePreset.mockReset()
registerRepoHandlers(mainWindow as never, mockStore as never)
})
it('normalizes and de-duplicates saved sparse preset directories', () => {
const saved = handlers.get('sparsePresets:save')!(null, {
repoId: 'repo-1',
name: ' Web preset ',
directories: [' packages/web ', 'apps\\api\\', 'packages/web/', '.', '']
})
expect(mockStore.saveSparsePreset).toHaveBeenCalledWith(
expect.objectContaining({
id: 'preset-new',
repoId: 'repo-1',
name: 'Web preset',
directories: ['packages/web', 'apps/api']
})
)
expect(saved).toEqual(
expect.objectContaining({
name: 'Web preset',
directories: ['packages/web', 'apps/api']
})
)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('sparsePresets:changed', {
repoId: 'repo-1'
})
})
it('preserves createdAt and id when editing an existing preset', () => {
mockStore.getSparsePresets.mockReturnValue([
makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Old', createdAt: 123 })
])
const saved = handlers.get('sparsePresets:save')!(null, {
repoId: 'repo-1',
id: 'preset-1',
name: 'New',
directories: ['packages/new']
})
expect(saved).toEqual(
expect.objectContaining({
id: 'preset-1',
name: 'New',
createdAt: 123,
directories: ['packages/new']
})
)
})
it('rejects invalid sparse preset saves before persistence', () => {
expect(() =>
handlers.get('sparsePresets:save')!(null, {
repoId: 'repo-1',
name: 'Web',
directories: ['../secrets']
})
).toThrow('Preset directories must be repo-relative paths.')
expect(() =>
handlers.get('sparsePresets:save')!(null, {
repoId: 'repo-1',
name: ' ',
directories: ['packages/web']
})
).toThrow('Preset name is required.')
expect(() =>
handlers.get('sparsePresets:save')!(null, {
repoId: 'repo-1',
name: 'Web',
directories: ['.', '']
})
).toThrow('Preset must have at least one directory.')
expect(mockStore.saveSparsePreset).not.toHaveBeenCalled()
})
it.each([
'/Users/me/repo/packages/web',
'C:\\repo\\packages\\web',
'\\\\server\\share\\repo',
'\\repo\\packages\\web'
])('rejects absolute sparse preset directory before normalization: %s', (directory) => {
expect(() =>
handlers.get('sparsePresets:save')!(null, {
repoId: 'repo-1',
name: 'Web',
directories: ['packages/web', directory]
})
).toThrow('Preset directories must be repo-relative paths.')
expect(mockStore.saveSparsePreset).not.toHaveBeenCalled()
})
it('rejects sparse preset saves for unknown repos', () => {
mockStore.getRepo.mockReturnValue(null)
expect(() =>
handlers.get('sparsePresets:save')!(null, {
repoId: 'missing',
name: 'Web',
directories: ['packages/web']
})
).toThrow('Repo "missing" not found')
})
it('rejects sparse preset removals for unknown repos before persistence', () => {
mockStore.getRepo.mockReturnValue(null)
expect(() =>
handlers.get('sparsePresets:remove')!(null, {
repoId: 'missing',
presetId: 'preset-1'
})
).toThrow('Repo "missing" not found')
expect(mockStore.removeSparsePreset).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('sparsePresets:changed', {
repoId: 'missing'
})
})
})
+90 -1
View File
@@ -5,7 +5,7 @@ import type { BrowserWindow } from 'electron'
import { dialog, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type { Repo, BaseRefDefaultResult } from '../../shared/types'
import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { REPO_COLORS } from '../../shared/constants'
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
@@ -28,6 +28,7 @@ import {
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getActiveMultiplexer } from './ssh'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
// Why: module-scoped so the abort handle survives window re-creation on macOS.
// registerRepoHandlers is called again when a new BrowserWindow is created,
@@ -50,6 +51,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
ipcMain.removeHandler('repos:addRemote')
ipcMain.removeHandler('sparsePresets:list')
ipcMain.removeHandler('sparsePresets:save')
ipcMain.removeHandler('sparsePresets:remove')
ipcMain.handle('repos:list', () => {
return store.getRepos()
@@ -219,6 +223,55 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
// ── Sparse presets ─────────────────────────────────────────────
// Why: presets are repo-scoped reusable directory lists used by the
// new-workspace composer. Persisted via Store and broadcast back to the
// renderer so any open composer reflects new/edited/deleted presets
// immediately.
ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => {
return store.getSparsePresets(args.repoId)
})
ipcMain.handle(
'sparsePresets:save',
(
_event,
args: { repoId: string; id?: string; name: string; directories: string[] }
): SparsePreset => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo "${args.repoId}" not found`)
}
const name = normalizeSparsePresetName(args.name)
const directories = normalizeSparsePresetDirectories(args.directories)
const now = Date.now()
const existing = args.id
? store.getSparsePresets(args.repoId).find((preset) => preset.id === args.id)
: undefined
const preset: SparsePreset = {
id: existing?.id ?? randomUUID(),
repoId: args.repoId,
name,
directories,
createdAt: existing?.createdAt ?? now,
updatedAt: now
}
const saved = store.saveSparsePreset(preset)
notifySparsePresetsChanged(mainWindow, args.repoId)
return saved
}
)
ipcMain.handle('sparsePresets:remove', (_event, args: { repoId: string; presetId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo "${args.repoId}" not found`)
}
store.removeSparsePreset(args.repoId, args.presetId)
notifySparsePresetsChanged(mainWindow, args.repoId)
})
ipcMain.handle('repos:pickFolder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
@@ -514,3 +567,39 @@ function notifyReposChanged(mainWindow: BrowserWindow): void {
mainWindow.webContents.send('repos:changed')
}
}
function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('sparsePresets:changed', { repoId })
}
}
function normalizeSparsePresetName(name: string): string {
const trimmed = name.trim()
if (!trimmed) {
throw new Error('Preset name is required.')
}
if (trimmed.length > 80) {
throw new Error('Preset name is too long.')
}
return trimmed
}
function normalizeSparsePresetDirectories(directories: string[]): string[] {
let normalized: string[]
try {
normalized = normalizeSparseDirectories(directories)
} catch (err) {
if (
err instanceof Error &&
err.message === 'Sparse checkout directories must be repo-relative paths.'
) {
throw new Error('Preset directories must be repo-relative paths.')
}
throw err
}
if (normalized.length === 0) {
throw new Error('Preset must have at least one directory.')
}
return normalized
}
@@ -0,0 +1,29 @@
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
function isAbsoluteSparseDirectoryPath(entry: string): boolean {
return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
}
export function normalizeSparseDirectories(directories: string[]): string[] {
const seen = new Set<string>()
return directories
.map((entry) => entry.trim())
.map((entry) => {
// Why: absolute paths can look repo-relative after slash normalization.
if (isAbsoluteSparseDirectoryPath(entry)) {
throw new Error('Sparse checkout directories must be repo-relative paths.')
}
return entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
})
.filter((entry) => entry.length > 0 && entry !== '.')
.filter((entry) => {
if (entry.split('/').includes('..')) {
throw new Error('Sparse checkout directories must be repo-relative paths.')
}
if (seen.has(entry)) {
return false
}
seen.add(entry)
return true
})
}
+8
View File
@@ -160,6 +160,7 @@ export function mergeWorktree(
head: git.head,
branch: git.branch,
isBare: git.isBare,
...(git.isSparse === true ? { isSparse: true } : {}),
isMainWorktree: git.isMainWorktree,
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
comment: meta?.comment || '',
@@ -171,6 +172,13 @@ export function mergeWorktree(
isPinned: meta?.isPinned ?? false,
sortOrder: meta?.sortOrder ?? 0,
lastActivityAt: meta?.lastActivityAt ?? 0,
...(git.isSparse === true
? {
sparseDirectories: meta?.sparseDirectories,
sparseBaseRef: meta?.sparseBaseRef,
sparsePresetId: meta?.sparsePresetId
}
: {}),
// Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in
// shared/types) and forwarded verbatim so the renderer store mirrors
// on-disk state. `undefined` here means the worktree has no comments yet.
+55 -8
View File
@@ -13,7 +13,7 @@ import type {
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree } from '../git/worktree'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
import { gitExecFileAsync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
@@ -31,6 +31,7 @@ import {
areWorktreePathsEqual
} from './worktree-logic'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
@@ -44,6 +45,10 @@ export async function createRemoteWorktree(
store: Store,
mainWindow: BrowserWindow
): Promise<CreateWorktreeResult> {
if (args.sparseCheckout) {
throw new Error('Sparse checkout is not supported for remote SSH repos yet.')
}
const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined
if (!provider) {
throw new Error(`No git provider for connection "${repo.connectionId}"`)
@@ -307,6 +312,32 @@ export async function createLocalWorktree(
// Resolve it before mutating git state so missing UI input cannot strand
// a real worktree on disk while the renderer reports "create failed".
const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false
const sparseDirectories = args.sparseCheckout
? normalizeSparseDirectories(args.sparseCheckout.directories)
: []
if (args.sparseCheckout && sparseDirectories.length === 0) {
throw new Error('Sparse checkout requires at least one repo-relative directory.')
}
let sparsePresetId: string | undefined
if (args.sparseCheckout?.presetId) {
const preset = store
.getSparsePresets(repo.id)
.find((entry) => entry.id === args.sparseCheckout?.presetId)
if (preset?.repoId === repo.id) {
try {
const presetDirectories = normalizeSparseDirectories(preset.directories)
// Why: use Set-based comparison so directory order does not affect
// attribution — matches the renderer's sparseDirectoriesMatch logic.
const presetSet = new Set(presetDirectories)
const directoriesMatch =
presetDirectories.length === sparseDirectories.length &&
sparseDirectories.every((entry) => presetSet.has(entry))
sparsePresetId = directoriesMatch ? preset.id : undefined
} catch {
// Why: corrupt preset data should not block creation or falsely label the new worktree.
}
}
}
// Why: `git fetch` previously blocked worktree creation for 1–5s on every
// click, even though the fetch result isn't actually required — the
@@ -319,13 +350,22 @@ export async function createLocalWorktree(
// Fetch is best-effort — don't block worktree creation if offline
})
await addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
await (sparseDirectories.length > 0
? addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
: addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
))
// Re-list to get the freshly created worktree info
const gitWorktrees = await listWorktrees(repo.path)
@@ -342,6 +382,13 @@ export async function createLocalWorktree(
lastActivityAt: Date.now(),
...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
: {}),
...(sparseDirectories.length > 0
? {
sparseDirectories,
sparseBaseRef: baseBranch,
sparsePresetId
}
: {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
+174
View File
@@ -6,6 +6,7 @@ const {
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
addSparseWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
@@ -26,6 +27,7 @@ const {
removeHandlerMock: vi.fn(),
listWorktreesMock: vi.fn(),
addWorktreeMock: vi.fn(),
addSparseWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
getGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
@@ -53,6 +55,7 @@ vi.mock('electron', () => ({
vi.mock('../git/worktree', () => ({
listWorktrees: listWorktreesMock,
addWorktree: addWorktreeMock,
addSparseWorktree: addSparseWorktreeMock,
removeWorktree: removeWorktreeMock
}))
@@ -113,6 +116,7 @@ describe('registerWorktreeHandlers', () => {
const store = {
getRepos: vi.fn(),
getRepo: vi.fn(),
getSparsePresets: vi.fn(),
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
@@ -125,6 +129,7 @@ describe('registerWorktreeHandlers', () => {
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
addSparseWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
@@ -143,6 +148,7 @@ describe('registerWorktreeHandlers', () => {
mainWindow.webContents.send,
store.getRepos,
store.getRepo,
store.getSparsePresets,
store.getSettings,
store.getWorktreeMeta,
store.setWorktreeMeta,
@@ -168,6 +174,7 @@ describe('registerWorktreeHandlers', () => {
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
store.getSparsePresets.mockReturnValue([])
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
@@ -471,6 +478,173 @@ describe('registerWorktreeHandlers', () => {
)
})
it('creates a sparse worktree and persists its sparse metadata', async () => {
listWorktreesMock.mockResolvedValue([
{
...createdWorktreeList[0],
isSparse: true
}
])
store.setWorktreeMeta.mockReturnValue({
sparseDirectories: ['packages/web', 'apps/api'],
sparseBaseRef: 'origin/main',
sparsePresetId: 'preset-1'
})
store.getSparsePresets.mockReturnValue([
{
id: 'preset-1',
repoId: 'repo-1',
name: 'Frontend and API',
directories: ['packages/web', 'apps/api'],
createdAt: 1,
updatedAt: 1
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: [' packages/web ', 'apps\\api\\', 'packages/web/'],
presetId: 'preset-1'
}
})
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(addSparseWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard',
'improve-dashboard',
['packages/web', 'apps/api'],
'origin/main',
false
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/improve-dashboard',
expect.objectContaining({
sparseDirectories: ['packages/web', 'apps/api'],
sparseBaseRef: 'origin/main',
sparsePresetId: 'preset-1'
})
)
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/workspace/improve-dashboard',
sparseDirectories: ['packages/web', 'apps/api'],
sparseBaseRef: 'origin/main',
sparsePresetId: 'preset-1'
})
})
})
it('clears sparse preset attribution when the preset id does not belong to the repo', async () => {
listWorktreesMock.mockResolvedValue([
{
...createdWorktreeList[0],
isSparse: true
}
])
store.getSparsePresets.mockReturnValue([
{
id: 'preset-2',
repoId: 'repo-1',
name: 'Other preset',
directories: ['packages/web'],
createdAt: 1,
updatedAt: 1
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: ['packages/web'],
presetId: 'preset-1'
}
})
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/improve-dashboard',
expect.objectContaining({
sparseDirectories: ['packages/web'],
sparseBaseRef: 'origin/main',
sparsePresetId: undefined
})
)
})
it('clears sparse preset attribution when normalized directories do not match', async () => {
listWorktreesMock.mockResolvedValue([
{
...createdWorktreeList[0],
isSparse: true
}
])
store.getSparsePresets.mockReturnValue([
{
id: 'preset-1',
repoId: 'repo-1',
name: 'Frontend and API',
directories: ['packages/web', 'apps/api'],
createdAt: 1,
updatedAt: 1
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: ['packages/web'],
presetId: 'preset-1'
}
})
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/improve-dashboard',
expect.objectContaining({
sparseDirectories: ['packages/web'],
sparseBaseRef: 'origin/main',
sparsePresetId: undefined
})
)
})
it('rejects sparse checkout directories that traverse above the repo root', async () => {
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: ['packages/web', '../secrets']
}
})
).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
expect(addSparseWorktreeMock).not.toHaveBeenCalled()
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
'rejects absolute sparse checkout directory before normalization: %s',
async (directory) => {
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: ['packages/web', directory]
}
})
).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
expect(addSparseWorktreeMock).not.toHaveBeenCalled()
expect(addWorktreeMock).not.toHaveBeenCalled()
}
)
it('still returns the created worktree when setup runner generation fails', async () => {
listWorktreesMock.mockResolvedValue(createdWorktreeList)
getEffectiveHooksMock.mockReturnValue({
+35 -1
View File
@@ -6,7 +6,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkS
import { writeFile, rename, mkdir, rm } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
import type {
PersistedState,
Repo,
SparsePreset,
WorktreeMeta,
GlobalSettings
} from '../shared/types'
import type { SshTarget } from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
import { getGitUsername } from './git/repo'
@@ -343,6 +349,9 @@ export class Store {
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
// Why: presets are repo-scoped, so removing the repo means the presets
// can never be referenced again — drop them with the parent.
delete this.state.sparsePresetsByRepo[id]
// Clean up worktree meta for this repo
const prefix = `${id}::`
for (const key of Object.keys(this.state.worktreeMeta)) {
@@ -393,6 +402,31 @@ export class Store {
}
}
// ── Sparse Presets ─────────────────────────────────────────────────
getSparsePresets(repoId: string): SparsePreset[] {
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
saveSparsePreset(preset: SparsePreset): SparsePreset {
const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? []
const index = existing.findIndex((entry) => entry.id === preset.id)
this.state.sparsePresetsByRepo[preset.repoId] =
index === -1
? [...existing, preset]
: existing.map((entry, i) => (i === index ? preset : entry))
this.scheduleSave()
return preset
}
removeSparsePreset(repoId: string, presetId: string): void {
const existing = this.state.sparsePresetsByRepo[repoId] ?? []
this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId)
this.scheduleSave()
}
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
+5 -1
View File
@@ -70,7 +70,11 @@ async function doResolve(pid: number): Promise<string> {
}
try {
const { stdout } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], {
// Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them
// and emits cwd records for every process on the system, so the n-line
// scan below picks up the first unrelated process (often pid ~391 with
// cwd `/`) and returns `/` regardless of the target pid's real cwd.
const { stdout } = await execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
encoding: 'utf-8',
timeout: LSOF_TIMEOUT_MS
})
+19 -6
View File
@@ -8,6 +8,7 @@ import type {
BrowserSessionProfileSource,
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
CreateWorktreeArgs,
CreateWorktreeResult,
CustomPetModel,
DirEntry,
@@ -37,6 +38,7 @@ import type {
LinearLabel,
LinearMember,
LinearTeam,
MarkdownDocument,
GitHubIssueUpdate,
NotificationDispatchRequest,
NotificationDispatchResult,
@@ -46,6 +48,7 @@ import type {
PRComment,
PRInfo,
Repo,
SparsePreset,
SearchOptions,
SearchResult,
StatsSummary,
@@ -329,15 +332,21 @@ export type PreloadApi = {
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
onChanged: (callback: () => void) => () => void
}
sparsePresets: {
list: (args: { repoId: string }) => Promise<SparsePreset[]>
save: (args: {
repoId: string
id?: string
name: string
directories: string[]
}) => Promise<SparsePreset>
remove: (args: { repoId: string; presetId: string }) => Promise<void>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
}
worktrees: {
list: (args: { repoId: string }) => Promise<Worktree[]>
listAll: () => Promise<Worktree[]>
create: (args: {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}) => Promise<CreateWorktreeResult>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
resolvePrBase: (args: {
repoId: string
prNumber: number
@@ -636,6 +645,10 @@ export type PreloadApi = {
filePath: string
connectionId?: string
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
listMarkdownDocuments: (args: {
rootPath: string
connectionId?: string
}) => Promise<MarkdownDocument[]>
writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise<void>
createFile: (args: { filePath: string; connectionId?: string }) => Promise<void>
createDir: (args: { dirPath: string; connectionId?: string }) => Promise<void>
+30 -6
View File
@@ -9,6 +9,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type {
BaseRefDefaultResult,
CreateWorktreeArgs,
CustomPetModel,
FsChangedPayload,
GitHubAssignableUser,
@@ -247,18 +248,36 @@ const api = {
}
},
sparsePresets: {
list: (args: { repoId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('sparsePresets:list', args),
save: (args: {
repoId: string
id?: string
name: string
directories: string[]
}): Promise<unknown> => ipcRenderer.invoke('sparsePresets:save', args),
remove: (args: { repoId: string; presetId: string }): Promise<void> =>
ipcRenderer.invoke('sparsePresets:remove', args),
onChanged: (callback: (data: { repoId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) =>
callback(data)
ipcRenderer.on('sparsePresets:changed', listener)
return () => ipcRenderer.removeListener('sparsePresets:changed', listener)
}
},
worktrees: {
list: (args: { repoId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('worktrees:list', args),
listAll: (): Promise<unknown[]> => ipcRenderer.invoke('worktrees:listAll'),
create: (args: {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}): Promise<unknown> => ipcRenderer.invoke('worktrees:create', args),
create: (args: CreateWorktreeArgs): Promise<unknown> =>
ipcRenderer.invoke('worktrees:create', args),
resolvePrBase: (args: {
repoId: string
@@ -1107,6 +1126,11 @@ const api = {
connectionId?: string
}): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> =>
ipcRenderer.invoke('fs:readFile', args),
listMarkdownDocuments: (args: {
rootPath: string
connectionId?: string
}): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> =>
ipcRenderer.invoke('fs:listMarkdownDocuments', args),
writeFile: (args: {
filePath: string
content: string
+12 -4
View File
@@ -42,10 +42,18 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi
// lsof returns ALL open files (sockets, log files, TTYs) and the first `n`-line
// could be any of them — not the actual working directory.
try {
const { stdout: output } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], {
encoding: 'utf-8',
timeout: 3000
})
// Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them
// and emits cwd records for every process on the system, so the n-line
// scan below picks up the first unrelated process (often pid ~391 with
// cwd `/`) and returns `/` regardless of the target pid's real cwd.
const { stdout: output } = await execFile(
'lsof',
['-a', '-p', String(pid), '-d', 'cwd', '-Fn'],
{
encoding: 'utf-8',
timeout: 3000
}
)
const lines = output.split('\n')
for (const line of lines) {
if (line.startsWith('n') && line.includes('/')) {
+22 -1
View File
@@ -2,7 +2,7 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight, Search } from 'lucide-react'
import {
FOCUS_TERMINAL_PANE_EVENT,
SYNC_FIT_PANES_EVENT,
@@ -870,6 +870,27 @@ function App(): React.JSX.Element {
</PopoverContent>
</Popover>
) : null}
{/* Why: always rendered (not gated on showSidebar) because the
worktree palette is global — it mirrors the ⌘J/Ctrl+Shift+J
shortcut which is live from every view including Settings and
Landing. The `sidebar-toggle` class is the shared titlebar
icon-button style used by back/forward/sidebar-toggle/right-
sidebar neighbors; the name is historical and applies to any
titlebar icon button, not only sidebar toggles. */}
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={() => actions.openModal('worktree-palette')}
aria-label="Search worktrees and browser tabs"
>
<Search size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Search (${isMac ? '⌘J' : 'Ctrl+Shift+J'})`}
</TooltipContent>
</Tooltip>
</div>
{/* Why: Back/Forward traverse mixed worktree + Tasks history, so the
cluster is shown wherever the history shortcut is live (terminal or
+7
View File
@@ -897,6 +897,13 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 52%, transparent);
}
.monaco-editor .monaco-markdown-doc-link {
color: #58a6ff !important;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
/* Why: Monaco's find-widget button tooltips (Close, Find in Selection, etc.)
render inside a `.context-view` wrapper → `.workbench-hover-container` →
`.workbench-hover.compact`, positioned above the target button. The wrapper's
@@ -142,6 +142,16 @@
text-decoration-color: currentColor;
}
.markdown-body .markdown-doc-link {
text-decoration-style: solid;
}
.markdown-body .markdown-doc-link-broken {
color: var(--muted-foreground);
cursor: default !important;
text-decoration-style: dotted;
}
.markdown-light .markdown-body a {
color: #0969da;
}
@@ -150,6 +160,11 @@
color: #58a6ff;
}
.markdown-light .markdown-body .markdown-doc-link-broken,
.markdown-dark .markdown-body .markdown-doc-link-broken {
color: var(--muted-foreground);
}
.markdown-body code {
padding: 0.2em 0.4em;
border-radius: 5px;
@@ -592,6 +592,110 @@
color: var(--muted-foreground);
}
.rich-markdown-doc-link-menu {
position: absolute;
z-index: 30;
display: flex;
width: min(360px, calc(100% - 24px));
max-height: 320px;
flex-direction: column;
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--background) 92%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(16px);
}
.rich-markdown-doc-link-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: inherit;
text-align: left;
}
.rich-markdown-doc-link-item:hover,
.rich-markdown-doc-link-item.is-active {
background: var(--accent);
}
.rich-markdown-doc-link-item.is-empty {
color: var(--muted-foreground);
font-size: 13px;
font-style: italic;
cursor: default;
}
.rich-markdown-doc-link-item.is-empty:hover {
background: transparent;
}
.rich-markdown-doc-link-footer {
padding: 6px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
color: var(--muted-foreground);
font-size: 11px;
}
.rich-markdown-doc-link-hint {
padding: 6px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
color: var(--muted-foreground);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
/* ── Doc Links ───────────────────────────────────────── */
.rich-markdown-editor .rich-markdown-doc-link {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-doc-link {
color: #58a6ff;
}
.rich-markdown-editor .rich-markdown-doc-link.rich-markdown-doc-link--missing {
color: var(--muted-foreground);
text-decoration-style: dashed;
}
.rich-markdown-mod-held .rich-markdown-editor .rich-markdown-doc-link {
cursor: pointer;
}
.rich-markdown-mod-held
.rich-markdown-editor
.rich-markdown-doc-link.rich-markdown-doc-link--missing {
cursor: default;
}
/* In-progress [[target]] text while cursor is inside. Matches the committed
doc-link styling so the visual doesn't jump when the atom node takes over. */
.rich-markdown-editor .rich-markdown-doc-link-preview {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-doc-link-preview {
color: #58a6ff;
}
.rich-markdown-editor .rich-markdown-doc-link-preview.rich-markdown-doc-link-preview--missing {
color: var(--muted-foreground);
text-decoration-style: dashed;
}
/* ── Link Bubble ──────────────────────────────────────── */
.rich-markdown-link-bubble {
@@ -18,7 +18,8 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { TuiAgent } from '../../../shared/types'
import type { SparsePreset, TuiAgent } from '../../../shared/types'
import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect'
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
@@ -51,6 +52,10 @@ type NewWorkspaceComposerCardProps = {
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: string | null
canUseSparseCheckout: boolean
sparsePresets: SparsePreset[]
sparseSelectedPresetId: string | null
onSparseSelectPreset: (preset: SparsePreset | null) => void
}
function SetupCommandPreview({
@@ -186,7 +191,11 @@ export default function NewWorkspaceComposerCard({
onSetupDecisionChange,
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError
createError,
canUseSparseCheckout,
sparsePresets,
sparseSelectedPresetId,
onSparseSelectPreset
}: NewWorkspaceComposerCardProps): React.JSX.Element {
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
const openModal = useAppStore((s) => s.openModal)
@@ -337,8 +346,8 @@ export default function NewWorkspaceComposerCard({
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
>
@@ -347,7 +356,14 @@ export default function NewWorkspaceComposerCard({
textarea's 3px outset focus ring has horizontal breathing room
inside the overflow-hidden drawer above. Without it the ring
gets clipped on the right edge when the field is focused. */}
<div className="space-y-4 px-1 pt-1">
<div
className={cn(
'space-y-4 px-1 pt-1 pb-3 transition-[opacity,transform] duration-150 ease-out',
advancedOpen
? 'translate-y-0 opacity-100 delay-200'
: '-translate-y-1 opacity-0 delay-0'
)}
>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Note</label>
<textarea
@@ -449,6 +465,22 @@ export default function NewWorkspaceComposerCard({
) : null}
</div>
) : null}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sparse checkout</label>
<SparseCheckoutPresetSelect
repoId={repoId}
presets={sparsePresets}
selectedPresetId={sparseSelectedPresetId}
onSelectPreset={onSparseSelectPreset}
disabled={!canUseSparseCheckout}
/>
{!canUseSparseCheckout ? (
<p className="text-[11px] text-muted-foreground">
Only available for local repositories.
</p>
) : null}
</div>
</div>
</div>
</div>
@@ -476,7 +476,10 @@ function AnimatedTabPanels({
// leaving no room for a ring to paint. `overflow-clip-margin` gives
// the ring breathing room on every side without re-introducing scroll
// containers or letting the inactive panel leak layout.
className="relative overflow-clip transition-[height] duration-200 ease-out"
className={cn(
'relative overflow-clip',
isAnimating && 'transition-[height] duration-200 ease-out'
)}
style={{
...(wrapperHeight !== null ? { height: wrapperHeight } : null),
overflowClipMargin: '8px'
@@ -9,6 +9,7 @@ import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import { useMarkdownDocuments } from './useMarkdownDocuments'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
@@ -84,7 +85,7 @@ export function EditorContent({
const openConflictReview = useAppStore((s) => s.openConflictReview)
const closeFile = useAppStore((s) => s.closeFile)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const md = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
const activeConflictEntry =
worktreeEntries.find((entry) => entry.path === activeFile.relativePath) ?? null
@@ -107,7 +108,7 @@ export function EditorContent({
content={editBuffers[activeFile.id] ?? fc.content}
language={resolvedLanguage}
onContentChange={handleContentChange}
onSave={handleSave}
onSave={isMarkdown ? md.mdSave : handleSave}
revealLine={
pendingEditorReveal?.filePath === activeFile.filePath ? pendingEditorReveal.line : undefined
}
@@ -121,6 +122,7 @@ export function EditorContent({
? pendingEditorReveal.matchLength
: undefined
}
markdownDocuments={isMarkdown ? md.markdownDocuments : undefined}
/>
)
@@ -169,8 +171,8 @@ export function EditorContent({
: handleContentChange
const onSaveWithFm = fm
? (body: string): Promise<void> => handleSave(prependFrontMatter(fm.raw, body))
: handleSave
? (body: string): Promise<void> => md.mdSave(prependFrontMatter(fm.raw, body))
: md.mdSave
return (
<div className="flex h-full min-h-0 flex-col">
@@ -190,6 +192,8 @@ export function EditorContent({
onContentChange={onContentChangeWithFm}
onDirtyStateHint={handleDirtyStateHint}
onSave={onSaveWithFm}
onOpenDocLink={md.onOpenDocLink}
markdownDocuments={md.markdownDocuments}
// Why: render the front-matter banner below the editor toolbar
// (inside the editor shell) so formatting controls remain at
// the top of the pane — the banner is read-only context, not
@@ -221,6 +225,7 @@ export function EditorContent({
content={currentContent}
filePath={activeFile.filePath}
scrollCacheKey={`${editorViewStateKey}:preview`}
{...md.previewProps}
/>
</div>
</div>
@@ -302,6 +307,7 @@ export function EditorContent({
filePath={activeFile.filePath}
scrollCacheKey={markdownPreviewViewStateKey}
initialAnchor={activeFile.markdownPreviewAnchor ?? null}
{...md.previewProps}
/>
</div>
)
@@ -403,6 +409,7 @@ export function EditorContent({
content={modifiedDiffContent}
filePath={activeFile.filePath}
scrollCacheKey={`${diffViewStateKey}:preview`}
{...md.previewProps}
/>
</div>
</div>
@@ -421,7 +428,7 @@ export function EditorContent({
editable={isEditable}
worktreeId={activeFile.worktreeId}
onContentChange={isEditable ? handleContentChange : undefined}
onSave={isEditable ? handleSave : undefined}
onSave={isEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined}
/>
)
}
@@ -23,12 +23,18 @@ import { toast } from 'sonner'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { detectLanguage } from '@/lib/language-detect'
import type { Worktree } from '../../../../shared/types'
import type { MarkdownDocument, Worktree } from '../../../../shared/types'
import {
fileUrlToAbsolutePath,
getMarkdownPreviewLinkTarget,
resolveMarkdownPreviewHref
} from './markdown-preview-links'
import {
createMarkdownDocumentIndex,
parseMarkdownDocLinkHref,
remarkMarkdownDocLinks,
resolveMarkdownDocLink
} from './markdown-doc-links'
import { absolutePathToFileUri, resolveMarkdownLinkTarget } from './markdown-internal-links'
import { useLocalImageSrc } from './useLocalImageSrc'
import CodeBlockCopyButton from './CodeBlockCopyButton'
@@ -47,6 +53,8 @@ type MarkdownPreviewProps = {
filePath: string
scrollCacheKey: string
initialAnchor?: string | null
markdownDocuments?: MarkdownDocument[]
onOpenDocument?: (document: MarkdownDocument) => void | Promise<void>
}
const markdownPreviewSanitizeSchema = {
@@ -144,7 +152,9 @@ export default function MarkdownPreview({
content,
filePath,
scrollCacheKey,
initialAnchor = null
initialAnchor = null,
markdownDocuments = [],
onOpenDocument
}: MarkdownPreviewProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
@@ -172,6 +182,10 @@ export default function MarkdownPreview({
const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef)
const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent])
const markdownDocumentIndex = useMemo(
() => createMarkdownDocumentIndex(markdownDocuments),
[markdownDocuments]
)
const frontMatterInner = useMemo(() => {
if (!frontMatter) {
return ''
@@ -410,7 +424,36 @@ export default function MarkdownPreview({
sluggerRef.current.reset()
const slugger = sluggerRef.current
return {
a: ({ href, children, ...props }) => {
a: ({ href, children, className, ...props }) => {
const docLinkTarget = parseMarkdownDocLinkHref(href)
if (docLinkTarget !== null) {
const resolution = resolveMarkdownDocLink(docLinkTarget, markdownDocumentIndex)
const resolvedDocument = resolution.status === 'resolved' ? resolution.document : null
const title =
resolution.status === 'ambiguous' ? 'Document link is ambiguous' : 'Document not found'
const handleDocLinkClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
event.preventDefault()
if (resolvedDocument && onOpenDocument) {
void onOpenDocument(resolvedDocument)
}
}
return (
<a
{...props}
href={href}
className={`${className ?? ''} ${
resolvedDocument ? 'markdown-doc-link' : 'markdown-doc-link-broken'
}`.trim()}
title={resolvedDocument ? undefined : title}
onClick={handleDocLinkClick}
>
{children}
</a>
)
}
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
if (!href) {
return
@@ -547,7 +590,13 @@ export default function MarkdownPreview({
}
return (
<a {...props} href={href} onClick={handleClick} style={{ cursor: 'pointer' }}>
<a
{...props}
href={href}
className={className}
onClick={handleClick}
style={{ cursor: 'pointer' }}
>
{children}
</a>
)
@@ -644,6 +693,8 @@ export default function MarkdownPreview({
filePath,
isDark,
isMac,
markdownDocumentIndex,
onOpenDocument,
openFile,
openMarkdownPreview,
scrollToAnchor,
@@ -748,7 +799,13 @@ export default function MarkdownPreview({
)}
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkBreaks, remarkFrontmatter, remarkMath]}
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkFrontmatter,
remarkMath,
remarkMarkdownDocLinks
]}
// Why: raw HTML must be sanitized before any trusted renderer expands
// it into richer DOM. Running KaTeX and syntax highlighting after
// sanitize preserves VS Code-style math/code rendering without having
@@ -1,15 +1,8 @@
import React, { useRef, useCallback, useEffect, useLayoutEffect, useState } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { Copy, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache'
import '@/lib/monaco-setup'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
@@ -22,6 +15,16 @@ import {
endProgrammaticContentSync,
shouldIgnoreMonacoContentChange
} from './monaco-programmatic-sync'
import {
clearMarkdownDocCompletionDocuments,
ensureMarkdownDocCompletionProvider,
setMarkdownDocCompletionDocuments
} from './monaco-markdown-doc-completions'
import { MonacoGutterContextMenu } from './MonacoGutterContextMenu'
import {
createMarkdownDocLinkDecorationController,
type MarkdownDocLinkDecorationController
} from './monaco-markdown-doc-link-decorations'
type MonacoEditorProps = {
filePath: string
@@ -34,6 +37,7 @@ type MonacoEditorProps = {
revealLine?: number
revealColumn?: number
revealMatchLength?: number
markdownDocuments?: MarkdownDocument[]
}
export default function MonacoEditor({
@@ -46,9 +50,14 @@ export default function MonacoEditor({
onSave,
revealLine,
revealColumn,
revealMatchLength
revealMatchLength,
markdownDocuments
}: MonacoEditorProps): React.JSX.Element {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const modelKeyRef = useRef<string | null>(null)
const languageRef = useRef(language)
languageRef.current = language
const markdownDocLinkDecorationsRef = useRef<MarkdownDocLinkDecorationController | null>(null)
const revealDecorationRef = useRef<editor.IEditorDecorationsCollection | null>(null)
const revealHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const revealRafRef = useRef<number | null>(null)
@@ -83,6 +92,22 @@ export default function MonacoEditor({
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const updateMarkdownCompletionDocuments = useCallback((): void => {
const modelKey = editorRef.current?.getModel()?.uri.toString() ?? null
if (modelKeyRef.current && modelKeyRef.current !== modelKey) {
clearMarkdownDocCompletionDocuments(modelKeyRef.current)
}
modelKeyRef.current = modelKey
if (!modelKey) {
return
}
if (language === 'markdown' && markdownDocuments) {
setMarkdownDocCompletionDocuments(modelKey, markdownDocuments)
} else {
clearMarkdownDocCompletionDocuments(modelKey)
}
}, [language, markdownDocuments])
const clearTransientRevealHighlight = useCallback(() => {
if (revealHighlightTimerRef.current !== null) {
clearTimeout(revealHighlightTimerRef.current)
@@ -161,6 +186,12 @@ export default function MonacoEditor({
const handleMount: OnMount = useCallback(
(editorInstance, monaco) => {
editorRef.current = editorInstance
markdownDocLinkDecorationsRef.current = createMarkdownDocLinkDecorationController(
editorInstance,
() => languageRef.current
)
ensureMarkdownDocCompletionProvider(monaco)
updateMarkdownCompletionDocuments()
// Why: see comment on contentRef — reconcile the retained model against
// the current prop before any user interaction so external changes that
@@ -261,7 +292,14 @@ export default function MonacoEditor({
}
}
},
[queueReveal, setupCopy, filePath, setEditorCursorLine, viewStateKey]
[
queueReveal,
setupCopy,
filePath,
setEditorCursorLine,
updateMarkdownCompletionDocuments,
viewStateKey
]
)
const handleChange = useCallback(
@@ -347,6 +385,24 @@ export default function MonacoEditor({
})
}, [editorFontSize, settings])
useEffect(() => {
markdownDocLinkDecorationsRef.current?.refresh()
}, [content, language])
useEffect(() => {
updateMarkdownCompletionDocuments()
}, [updateMarkdownCompletionDocuments])
useEffect(() => {
return () => {
if (modelKeyRef.current) {
clearMarkdownDocCompletionDocuments(modelKeyRef.current)
}
markdownDocLinkDecorationsRef.current?.dispose()
markdownDocLinkDecorationsRef.current = null
}
}, [])
useEffect(() => {
const handler = (event: Event): void => {
const detail = (event as CustomEvent).detail as
@@ -422,57 +478,14 @@ export default function MonacoEditor({
/>
{toastNode}
{/* Radix context menu for line number gutter right-click */}
<DropdownMenu open={gutterMenuOpen} onOpenChange={setGutterMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: gutterMenuPoint.x, top: gutterMenuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={0} align="start">
<DropdownMenuItem
onSelect={() => {
window.api.ui.writeClipboardText(`${filePath}#L${gutterMenuLine}`)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.api.ui.writeClipboardText(`${relativePath}#L${gutterMenuLine}`)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Rel. Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Derive worktree root from the absolute and relative paths
const worktreePath = filePath.slice(0, -(relativePath.length + 1))
const activeFile = useAppStore
.getState()
.openFiles.find((f) => f.filePath === filePath)
const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined
const url = await window.api.git.remoteFileUrl({
worktreePath,
relativePath,
line: gutterMenuLine,
connectionId
})
if (url) {
window.api.ui.writeClipboardText(url)
}
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Copy Remote URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MonacoGutterContextMenu
open={gutterMenuOpen}
onOpenChange={setGutterMenuOpen}
point={gutterMenuPoint}
line={gutterMenuLine}
filePath={filePath}
relativePath={relativePath}
/>
</div>
)
}
@@ -0,0 +1,80 @@
import React from 'react'
import { Copy, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
type MonacoGutterContextMenuProps = {
open: boolean
onOpenChange: (open: boolean) => void
point: { x: number; y: number }
line: number
filePath: string
relativePath: string
}
export function MonacoGutterContextMenu({
open,
onOpenChange,
point,
line,
filePath,
relativePath
}: MonacoGutterContextMenuProps): React.JSX.Element {
return (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: point.x, top: point.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={0} align="start">
<DropdownMenuItem onSelect={() => window.api.ui.writeClipboardText(`${filePath}#L${line}`)}>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => window.api.ui.writeClipboardText(`${relativePath}#L${line}`)}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
Copy Rel. Path to Line
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
const state = useAppStore.getState()
const activeFile = state.openFiles.find((f) => f.filePath === filePath)
if (!activeFile) {
return
}
const worktree = findWorktreeById(state.worktreesByRepo, activeFile.worktreeId)
if (!worktree) {
return
}
const connectionId = getConnectionId(activeFile?.worktreeId ?? null) ?? undefined
const url = await window.api.git.remoteFileUrl({
worktreePath: worktree.path,
relativePath,
line,
connectionId
})
if (url) {
window.api.ui.writeClipboardText(url)
}
}}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Copy Remote URL
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,70 @@
import React from 'react'
import type { Editor } from '@tiptap/react'
import { cn } from '@/lib/utils'
import { commitRow } from './rich-markdown-commands'
import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands'
type RichMarkdownDocLinkMenuProps = {
editor: Editor | null
menu: DocLinkMenuState
rows: DocLinkMenuRow[]
totalMatches: number
selectedIndex: number
}
export function RichMarkdownDocLinkMenu({
editor,
menu,
rows,
totalMatches,
selectedIndex
}: RichMarkdownDocLinkMenuProps): React.JSX.Element {
const overflow = totalMatches > rows.length
return (
<div
className="rich-markdown-doc-link-menu"
style={{ left: menu.left, top: menu.top }}
role="listbox"
aria-label="Markdown document links"
>
{rows.length === 0 ? (
<div className="rich-markdown-doc-link-item is-empty">No documents found</div>
) : (
rows.map((row, index) => {
const rowKey = row.kind === 'document' ? row.document.filePath : row.id
return (
<button
key={rowKey}
type="button"
className={cn('rich-markdown-doc-link-item', index === selectedIndex && 'is-active')}
// Why: mousedown inside the editor-mounted popover would otherwise
// blur the editor before click fires, losing the selection we need
// to run the commit transaction against.
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor && commitRow(editor, menu, row)}
>
{row.kind === 'document' ? (
<span className="flex min-w-0 flex-1 flex-col items-start">
<span className="truncate text-sm font-medium">{row.document.name}</span>
<span className="truncate text-xs text-muted-foreground">
{row.document.relativePath}
</span>
</span>
) : (
<span className="truncate text-sm">{row.label}</span>
)}
</button>
)
})
)}
{overflow ? (
<div className="rich-markdown-doc-link-footer">
Showing {rows.length} of {totalMatches}
</div>
) : null}
<div className="rich-markdown-doc-link-hint">
↑↓ navigate&nbsp;&nbsp;↵ select&nbsp;&nbsp;esc dismiss
</div>
</div>
)
}
@@ -2,14 +2,22 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import type { MarkdownDocument } from '../../../../shared/types'
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
import { RichMarkdownDocLinkMenu } from './RichMarkdownDocLinkMenu'
import { useAppStore } from '@/store'
import { RichMarkdownToolbar } from './RichMarkdownToolbar'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { useLocalImagePick } from './useLocalImagePick'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { slashCommands, syncSlashMenu } from './rich-markdown-commands'
import type { SlashCommand, SlashMenuState } from './rich-markdown-commands'
import { slashCommands, syncDocLinkMenu, syncSlashMenu } from './rich-markdown-commands'
import type {
DocLinkMenuRow,
DocLinkMenuState,
SlashCommand,
SlashMenuState
} from './rich-markdown-commands'
import { getMarkdownDocCompletionDocuments } from './markdown-doc-completions'
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
import {
@@ -42,6 +50,8 @@ type RichMarkdownEditorProps = {
onContentChange: (content: string) => void
onDirtyStateHint: (dirty: boolean) => void
onSave: (content: string) => void
onOpenDocLink?: (target: string) => void
markdownDocuments?: MarkdownDocument[]
// Why: front-matter is stripped from the rich editor's content but we still
// want it visible to the user. It renders between the toolbar and the editor
// surface so the formatting toolbar stays at the top of the pane.
@@ -61,6 +71,8 @@ export default function RichMarkdownEditor({
onContentChange,
onDirtyStateHint,
onSave,
onOpenDocLink,
markdownDocuments,
headerSlot
}: RichMarkdownEditorProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
@@ -78,14 +90,20 @@ export default function RichMarkdownEditor({
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null)
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0)
const [docLinkMenu, setDocLinkMenu] = useState<DocLinkMenuState | null>(null)
const [selectedDocLinkIndex, setSelectedDocLinkIndex] = useState(0)
const isMac = navigator.userAgent.includes('Mac')
const lastCommittedMarkdownRef = useRef(content)
const slashMenuRef = useRef<SlashMenuState | null>(null)
const filteredSlashCommandsRef = useRef<SlashCommand[]>(slashCommands)
const selectedCommandIndexRef = useRef(0)
const docLinkMenuRef = useRef<DocLinkMenuState | null>(null)
const filteredDocLinkRowsRef = useRef<DocLinkMenuRow[]>([])
const selectedDocLinkIndexRef = useRef(0)
const onContentChangeRef = useRef(onContentChange)
const onDirtyStateHintRef = useRef(onDirtyStateHint)
const onSaveRef = useRef(onSave)
const onOpenDocLinkRef = useRef(onOpenDocLink)
const handleLocalImagePickRef = useRef<() => void>(() => {})
const openSearchRef = useRef<() => void>(() => {})
// Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays
@@ -111,6 +129,7 @@ export default function RichMarkdownEditor({
onContentChangeRef.current = onContentChange
onDirtyStateHintRef.current = onDirtyStateHint
onSaveRef.current = onSave
onOpenDocLinkRef.current = onOpenDocLink
isEditingLinkRef.current = isEditingLink
const flushPendingSerialization = useCallback(() => {
@@ -161,13 +180,18 @@ export default function RichMarkdownEditor({
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
flushPendingSerialization,
openSearchRef,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSlashMenu
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}),
// Why: Cmd/Ctrl-click activates links via the shared classifier +
// dispatcher, so in-worktree .md links open in an Orca tab instead of the
@@ -183,6 +207,16 @@ export default function RichMarkdownEditor({
if (!ed || !modKey) {
return false
}
// Why: doc links are atom nodes (not marks), so resolve(pos).marks()
// won't find them. Check nodeAt(pos) first for doc link navigation.
const clickedNode = view.state.doc.nodeAt(pos)
if (clickedNode?.type.name === 'markdownDocLink') {
const target = clickedNode.attrs.target as string
if (target && onOpenDocLinkRef.current) {
onOpenDocLinkRef.current(target)
}
return true
}
const linkMark = view.state.doc
.resolve(pos)
.marks()
@@ -250,6 +284,7 @@ export default function RichMarkdownEditor({
},
onUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
// Why: bail out during normalizeSoftBreaks's onCreate transaction so the
// structural housekeeping doesn't mark the file dirty before the user
@@ -281,6 +316,7 @@ export default function RichMarkdownEditor({
},
onSelectionUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
// Sync link bubble: show preview when cursor is on a link, hide otherwise.
// Any selection change in the editor cancels an in-progress link edit.
@@ -341,6 +377,23 @@ export default function RichMarkdownEditor({
}
}, [editor, filePath])
// Why: the doc link NodeView reads the document list from storage to style
// resolved vs. missing links. The no-op transaction with meta flag triggers
// both nodeView `update` callbacks and the decoration plugin rebuild.
useEffect(() => {
if (editor && markdownDocuments) {
isApplyingProgrammaticUpdateRef.current = true
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(editor.storage as any).markdownDocLink.documents = markdownDocuments
const tr = editor.state.tr.setMeta('docLinksUpdated', true)
editor.view.dispatch(tr)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
}
}, [editor, markdownDocuments])
const handleLocalImagePick = useLocalImagePick(editor, filePath)
useEffect(() => {
@@ -410,6 +463,39 @@ export default function RichMarkdownEditor({
)
}, [filteredSlashCommands.length])
// Why: memo key is the `markdownDocuments` prop (stable reference from parent),
// not `editor.storage.markdownDocLink.documents`. The storage mirror is mutated
// in place by the extension so React would not see a new reference and the memo
// would stale-out. The prop is the single source of truth for filtering.
const DOC_LINK_MENU_MAX_ROWS = 20
const { docLinkRows, docLinkTotalMatches } = useMemo(() => {
if (!docLinkMenu || !markdownDocuments) {
return { docLinkRows: [] as DocLinkMenuRow[], docLinkTotalMatches: 0 }
}
const matches = getMarkdownDocCompletionDocuments(markdownDocuments, docLinkMenu.query)
const rows: DocLinkMenuRow[] = matches
.slice(0, DOC_LINK_MENU_MAX_ROWS)
.map((document) => ({ kind: 'document', document }))
return { docLinkRows: rows, docLinkTotalMatches: matches.length }
}, [docLinkMenu, markdownDocuments])
useEffect(() => {
docLinkMenuRef.current = docLinkMenu
}, [docLinkMenu])
useEffect(() => {
filteredDocLinkRowsRef.current = docLinkRows
}, [docLinkRows])
useEffect(() => {
selectedDocLinkIndexRef.current = selectedDocLinkIndex
}, [selectedDocLinkIndex])
useEffect(() => {
if (docLinkRows.length === 0) {
setSelectedDocLinkIndex(0)
return
}
setSelectedDocLinkIndex((currentIndex) => Math.min(currentIndex, docLinkRows.length - 1))
}, [docLinkRows.length])
useEffect(() => {
if (!editor) {
return
@@ -484,6 +570,7 @@ export default function RichMarkdownEditor({
isApplyingProgrammaticUpdateRef.current = false
}
syncSlashMenu(editor, rootRef.current, setSlashMenu)
syncDocLinkMenu(editor, rootRef.current, setDocLinkMenu)
// Why: fileId is part of the dep array so switching between files (where
// content can coincidentally match what was last committed for the prior
// file) still triggers the content-sync path and prevents cross-file
@@ -540,6 +627,15 @@ export default function RichMarkdownEditor({
onImagePick={handleLocalImagePick}
/>
) : null}
{docLinkMenu ? (
<RichMarkdownDocLinkMenu
editor={editor}
menu={docLinkMenu}
rows={docLinkRows}
totalMatches={docLinkTotalMatches}
selectedIndex={selectedDocLinkIndex}
/>
) : null}
</div>
)
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { MarkdownDocument } from '../../../../shared/types'
import {
getMarkdownDocCompletionContext,
getMarkdownDocCompletionDocuments
} from './markdown-doc-completions'
const documents: MarkdownDocument[] = [
{
filePath: '/repo/docs/setup.md',
relativePath: 'docs/setup.md',
basename: 'setup.md',
name: 'setup'
},
{
filePath: '/repo/notes/plan.md',
relativePath: 'notes/plan.md',
basename: 'plan.md',
name: 'plan'
}
]
describe('getMarkdownDocCompletionContext', () => {
it('detects partial doc links', () => {
expect(getMarkdownDocCompletionContext('before [[se')).toEqual({ partial: 'se' })
})
it('supports an empty partial after opening brackets', () => {
expect(getMarkdownDocCompletionContext('[[')).toEqual({ partial: '' })
})
it('rejects closed or malformed contexts', () => {
expect(getMarkdownDocCompletionContext('[[done]]')).toBeNull()
expect(getMarkdownDocCompletionContext('plain text')).toBeNull()
})
})
describe('getMarkdownDocCompletionDocuments', () => {
it('filters by name or relative path', () => {
expect(getMarkdownDocCompletionDocuments(documents, 'do')).toEqual([documents[0]])
expect(getMarkdownDocCompletionDocuments(documents, 'notes/pl')).toEqual([documents[1]])
})
it('normalizes Windows-style partial paths', () => {
expect(getMarkdownDocCompletionDocuments(documents, 'docs\\se')).toEqual([documents[0]])
})
})
@@ -0,0 +1,43 @@
import type { MarkdownDocument } from '../../../../shared/types'
export type MarkdownDocCompletionContext = {
partial: string
}
function normalizeCompletionText(value: string): string {
return value.trim().replaceAll('\\', '/').toLowerCase()
}
export function getMarkdownDocCompletionContext(
linePrefix: string
): MarkdownDocCompletionContext | null {
const start = linePrefix.lastIndexOf('[[')
if (start === -1) {
return null
}
const partial = linePrefix.slice(start + 2)
if (partial.includes('[') || partial.includes(']')) {
return null
}
return { partial }
}
export function getMarkdownDocCompletionDocuments(
documents: MarkdownDocument[],
partial: string
): MarkdownDocument[] {
const normalizedPartial = normalizeCompletionText(partial)
return documents
.filter((document) => {
if (!normalizedPartial) {
return true
}
return (
normalizeCompletionText(document.name).startsWith(normalizedPartial) ||
normalizeCompletionText(document.relativePath).startsWith(normalizedPartial)
)
})
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
@@ -0,0 +1,198 @@
import { describe, expect, it } from 'vitest'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import Markdown from 'react-markdown'
import type { MarkdownDocument } from '../../../../shared/types'
import {
createMarkdownDocumentIndex,
createMarkdownDocLinkHref,
parseMarkdownDocLinkHref,
remarkMarkdownDocLinks,
resolveMarkdownDocLink,
splitMarkdownDocLinkText
} from './markdown-doc-links'
type TestNode = {
type: string
value?: string
url?: string
children?: TestNode[]
}
const documents: MarkdownDocument[] = [
{
filePath: '/repo/docs/setup-guide.md',
relativePath: 'docs/setup-guide.md',
basename: 'setup-guide.md',
name: 'setup-guide'
},
{
filePath: '/repo/notes/README.MDX',
relativePath: 'notes/README.MDX',
basename: 'README.MDX',
name: 'README'
}
]
describe('splitMarkdownDocLinkText', () => {
it('splits one doc link', () => {
expect(splitMarkdownDocLinkText('See [[setup-guide]].')).toEqual([
{ type: 'text', value: 'See ' },
{ type: 'docLink', target: 'setup-guide', label: 'setup-guide' },
{ type: 'text', value: '.' }
])
})
it('splits multiple doc links', () => {
expect(splitMarkdownDocLinkText('[[one]] and [[two]]')).toEqual([
{ type: 'docLink', target: 'one', label: 'one' },
{ type: 'text', value: ' and ' },
{ type: 'docLink', target: 'two', label: 'two' }
])
})
it('leaves unsupported forms as text', () => {
expect(splitMarkdownDocLinkText('[[]] [[doc|Label]] [[bad [target]] [[draft')).toEqual([
{ type: 'text', value: '[[]]' },
{ type: 'text', value: ' [[doc|Label]]' },
{ type: 'text', value: ' [[bad [target]]' },
{ type: 'text', value: ' [[draft' }
])
})
})
describe('resolveMarkdownDocLink', () => {
it('resolves basename links', () => {
const result = resolveMarkdownDocLink('setup-guide', createMarkdownDocumentIndex(documents))
expect(result.status).toBe('resolved')
expect(result.status === 'resolved' ? result.document.relativePath : null).toBe(
'docs/setup-guide.md'
)
})
it('resolves relative paths with or without extensions', () => {
const index = createMarkdownDocumentIndex(documents)
expect(resolveMarkdownDocLink('docs/setup-guide', index)).toMatchObject({
status: 'resolved',
document: { relativePath: 'docs/setup-guide.md' }
})
expect(resolveMarkdownDocLink('notes/README.MDX', index)).toMatchObject({
status: 'resolved',
document: { relativePath: 'notes/README.MDX' }
})
})
it('treats duplicate normalized basenames as ambiguous', () => {
const index = createMarkdownDocumentIndex([
...documents,
{
filePath: '/repo/other/Setup-Guide.md',
relativePath: 'other/Setup-Guide.md',
basename: 'Setup-Guide.md',
name: 'Setup-Guide'
}
])
expect(resolveMarkdownDocLink('setup-guide', index).status).toBe('ambiguous')
expect(resolveMarkdownDocLink('docs/setup-guide', index).status).toBe('resolved')
})
it('prefers exact relative path over ambiguous extensionless match', () => {
const index = createMarkdownDocumentIndex([
{
filePath: '/repo/docs/guide.md',
relativePath: 'docs/guide.md',
basename: 'guide.md',
name: 'guide'
},
{
filePath: '/repo/docs/guide.mdx',
relativePath: 'docs/guide.mdx',
basename: 'guide.mdx',
name: 'guide'
}
])
expect(resolveMarkdownDocLink('docs/guide.md', index)).toMatchObject({
status: 'resolved',
document: { relativePath: 'docs/guide.md' }
})
expect(resolveMarkdownDocLink('docs/guide.mdx', index)).toMatchObject({
status: 'resolved',
document: { relativePath: 'docs/guide.mdx' }
})
expect(resolveMarkdownDocLink('docs/guide', index).status).toBe('ambiguous')
})
it('normalizes Windows-style targets', () => {
const result = resolveMarkdownDocLink(
'docs\\setup-guide.md',
createMarkdownDocumentIndex(documents)
)
expect(result.status).toBe('resolved')
})
it('returns missing for unknown links', () => {
expect(resolveMarkdownDocLink('missing-note', createMarkdownDocumentIndex(documents))).toEqual({
status: 'missing'
})
})
})
describe('doc link hrefs', () => {
it('round-trips encoded targets', () => {
const href = createMarkdownDocLinkHref('docs/setup guide')
expect(href).toBe('#orca-doc-link=docs%2Fsetup%20guide')
expect(parseMarkdownDocLinkHref(href)).toBe('docs/setup guide')
})
it('ignores normal hash links', () => {
expect(parseMarkdownDocLinkHref('#overview')).toBeNull()
})
})
describe('remarkMarkdownDocLinks', () => {
it('runs as a react-markdown remark plugin', () => {
const html = renderToStaticMarkup(
React.createElement(
Markdown,
{ remarkPlugins: [remarkMarkdownDocLinks] },
'link to [[other.md]]'
)
)
expect(html).toContain('<a href="#orca-doc-link=other.md">other.md</a>')
expect(html).not.toContain('[[other.md]]')
})
it('transforms text nodes but not code, links, or images', () => {
const tree: TestNode = {
type: 'root',
children: [
{ type: 'text', value: 'See [[setup-guide]]' },
{ type: 'inlineCode', value: '[[code]]' },
{ type: 'link', url: '[[url]]', children: [{ type: 'text', value: '[[label]]' }] },
{ type: 'image', url: '[[image]]' }
]
}
remarkMarkdownDocLinks()(tree)
expect(tree.children?.[0]).toMatchObject({
type: 'text',
value: 'See '
})
expect(tree.children?.[1]).toMatchObject({
type: 'link',
url: '#orca-doc-link=setup-guide'
})
expect(tree.children?.[2]).toEqual({ type: 'inlineCode', value: '[[code]]' })
expect(tree.children?.[3]).toEqual({
type: 'link',
url: '[[url]]',
children: [{ type: 'text', value: '[[label]]' }]
})
expect(tree.children?.[4]).toEqual({ type: 'image', url: '[[image]]' })
})
})
@@ -0,0 +1,220 @@
import type { MarkdownDocument } from '../../../../shared/types'
export const MARKDOWN_DOC_LINK_PREFIX = '#orca-doc-link='
type MarkdownTextNode = {
type: 'text'
value: string
}
type MarkdownLinkNode = {
type: 'link'
url: string
title: null
children: MarkdownTextNode[]
}
type MarkdownNode = {
type: string
value?: string
children?: MarkdownNode[]
}
export type MarkdownDocLinkTextPart =
| { type: 'text'; value: string }
| { type: 'docLink'; target: string; label: string }
export type MarkdownDocumentIndex = {
byName: Map<string, MarkdownDocument[]>
byRelativePath: Map<string, MarkdownDocument[]>
byRelativePathWithoutExtension: Map<string, MarkdownDocument[]>
}
export type MarkdownDocLinkResolution =
| { status: 'resolved'; document: MarkdownDocument }
| { status: 'missing' }
| { status: 'ambiguous'; matches: MarkdownDocument[] }
export function stripMarkdownExtension(value: string): string {
const lower = value.toLowerCase()
for (const extension of ['.markdown', '.mdx', '.md']) {
if (lower.endsWith(extension)) {
return value.slice(0, -extension.length)
}
}
return value
}
function normalizeDocLinkKey(value: string): string {
let normalized = value.trim().replaceAll('\\', '/')
while (normalized.startsWith('./')) {
normalized = normalized.slice(2)
}
return normalized.toLowerCase()
}
function addIndexedDocument(
map: Map<string, MarkdownDocument[]>,
key: string,
document: MarkdownDocument
): void {
const existing = map.get(key)
if (existing) {
existing.push(document)
} else {
map.set(key, [document])
}
}
function resolveMatches(matches: MarkdownDocument[] | undefined): MarkdownDocLinkResolution | null {
if (!matches) {
return null
}
return matches.length === 1
? { status: 'resolved', document: matches[0] }
: { status: 'ambiguous', matches }
}
export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): MarkdownDocumentIndex {
const byName = new Map<string, MarkdownDocument[]>()
const byRelativePath = new Map<string, MarkdownDocument[]>()
const byRelativePathWithoutExtension = new Map<string, MarkdownDocument[]>()
for (const document of documents) {
addIndexedDocument(byName, normalizeDocLinkKey(document.name), document)
addIndexedDocument(byRelativePath, normalizeDocLinkKey(document.relativePath), document)
addIndexedDocument(
byRelativePathWithoutExtension,
normalizeDocLinkKey(stripMarkdownExtension(document.relativePath)),
document
)
}
return { byName, byRelativePath, byRelativePathWithoutExtension }
}
export function resolveMarkdownDocLink(
target: string,
index: MarkdownDocumentIndex
): MarkdownDocLinkResolution {
const normalizedTarget = normalizeDocLinkKey(target)
const extensionlessTarget = stripMarkdownExtension(normalizedTarget)
// Why: exact relative path must be checked before the extensionless lookup
// so that [[docs/guide.md]] resolves uniquely even when docs/guide.mdx also
// exists (both share the extensionless key "docs/guide").
const relativeWithExtension = resolveMatches(index.byRelativePath.get(normalizedTarget))
if (relativeWithExtension) {
return relativeWithExtension
}
const relativeWithoutExtension = resolveMatches(
index.byRelativePathWithoutExtension.get(extensionlessTarget)
)
if (relativeWithoutExtension) {
return relativeWithoutExtension
}
if (!normalizedTarget.includes('/')) {
const byName = resolveMatches(index.byName.get(extensionlessTarget))
if (byName) {
return byName
}
}
return { status: 'missing' }
}
export function getMarkdownDocLinkTarget(rawTarget: string): string | null {
const target = rawTarget.trim()
if (!target || /[\r\n[\]|]/.test(target)) {
return null
}
return target
}
export function splitMarkdownDocLinkText(value: string): MarkdownDocLinkTextPart[] {
const parts: MarkdownDocLinkTextPart[] = []
let position = 0
while (position < value.length) {
const start = value.indexOf('[[', position)
if (start === -1) {
parts.push({ type: 'text', value: value.slice(position) })
break
}
const end = value.indexOf(']]', start + 2)
if (end === -1) {
parts.push({ type: 'text', value: value.slice(position) })
break
}
const target = getMarkdownDocLinkTarget(value.slice(start + 2, end))
if (!target) {
parts.push({ type: 'text', value: value.slice(position, end + 2) })
position = end + 2
continue
}
if (start > position) {
parts.push({ type: 'text', value: value.slice(position, start) })
}
parts.push({ type: 'docLink', target, label: target })
position = end + 2
}
return parts.length === 0 ? [{ type: 'text', value }] : parts
}
export function createMarkdownDocLinkHref(target: string): string {
return `${MARKDOWN_DOC_LINK_PREFIX}${encodeURIComponent(target)}`
}
export function parseMarkdownDocLinkHref(href: string | undefined): string | null {
if (!href?.startsWith(MARKDOWN_DOC_LINK_PREFIX)) {
return null
}
try {
return decodeURIComponent(href.slice(MARKDOWN_DOC_LINK_PREFIX.length))
} catch {
return null
}
}
function createDocLinkNode(target: string, label: string): MarkdownLinkNode {
return {
type: 'link',
url: createMarkdownDocLinkHref(target),
title: null,
children: [{ type: 'text', value: label }]
}
}
function transformChildren(node: MarkdownNode): void {
if (!node.children || node.type === 'link' || node.type === 'image') {
return
}
const nextChildren: MarkdownNode[] = []
for (const child of node.children) {
if (child.type === 'text' && child.value !== undefined) {
for (const part of splitMarkdownDocLinkText(child.value)) {
nextChildren.push(
part.type === 'text'
? { type: 'text', value: part.value }
: createDocLinkNode(part.target, part.label)
)
}
} else {
transformChildren(child)
nextChildren.push(child)
}
}
node.children = nextChildren
}
export function remarkMarkdownDocLinks(): (tree: MarkdownNode) => void {
return (tree) => transformChildren(tree)
}
@@ -35,4 +35,31 @@ describe('rich markdown round trip', () => {
it('preserves markdown tables', () => {
expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a')
})
it('preserves doc links', () => {
expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe(
'See [[setup-guide]] for details'
)
})
it('preserves adjacent doc links', () => {
expect(roundTripMarkdown('[[one]][[two]]\n')).toBe('[[one]][[two]]')
})
it('preserves doc links with paths', () => {
expect(roundTripMarkdown('Link to [[docs/setup-guide.md]]\n')).toBe(
'Link to [[docs/setup-guide.md]]'
)
})
it('does not encode invalid doc links', () => {
const result = roundTripMarkdown('Empty [[]] and piped [[a|b]]\n')
expect(result).toContain('[[]]')
expect(result).toContain('[[a|b]]')
})
it('preserves doc links inside fenced code blocks as plain text', () => {
const input = '```\n[[not-a-link]]\n```\n'
expect(roundTripMarkdown(input)).toBe('```\n[[not-a-link]]\n```')
})
})
@@ -0,0 +1,70 @@
import type { OnMount } from '@monaco-editor/react'
import type { IDisposable } from 'monaco-editor'
import type { MarkdownDocument } from '../../../../shared/types'
import {
getMarkdownDocCompletionContext,
getMarkdownDocCompletionDocuments
} from './markdown-doc-completions'
type MonacoApi = Parameters<OnMount>[1]
let provider: IDisposable | null = null
let providerMonaco: MonacoApi | null = null
const documentsByModel = new Map<string, MarkdownDocument[]>()
export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void {
// Why: if Monaco was torn down and re-created (e.g. window reload), the old
// provider reference is stale. Detect this by checking whether the Monaco
// instance changed and re-register.
if (provider && providerMonaco === monaco) {
return
}
if (provider) {
provider.dispose()
documentsByModel.clear()
}
providerMonaco = monaco
provider = monaco.languages.registerCompletionItemProvider('markdown', {
triggerCharacters: ['['],
provideCompletionItems(model, position) {
const line = model.getLineContent(position.lineNumber)
const context = getMarkdownDocCompletionContext(line.slice(0, position.column - 1))
if (!context) {
return { suggestions: [] }
}
const documents = documentsByModel.get(model.uri.toString()) ?? []
const suffix = line.slice(position.column - 1)
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column - context.partial.length,
endLineNumber: position.lineNumber,
endColumn: position.column
}
return {
suggestions: getMarkdownDocCompletionDocuments(documents, context.partial).map(
(document) => ({
label: document.name,
kind: monaco.languages.CompletionItemKind.File,
detail: document.relativePath,
insertText: suffix.startsWith(']]') ? document.name : `${document.name}]]`,
range
})
)
}
}
})
}
export function setMarkdownDocCompletionDocuments(
modelKey: string,
documents: MarkdownDocument[]
): void {
documentsByModel.set(modelKey, documents)
}
export function clearMarkdownDocCompletionDocuments(modelKey: string): void {
documentsByModel.delete(modelKey)
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { getMarkdownDocLinkDecorationRanges } from './monaco-markdown-doc-link-decorations'
describe('getMarkdownDocLinkDecorationRanges', () => {
it('returns Monaco ranges for valid doc links', () => {
expect(getMarkdownDocLinkDecorationRanges('link to [[other.md]]')).toEqual([
{
startLineNumber: 1,
startColumn: 9,
endLineNumber: 1,
endColumn: 21
}
])
})
it('ignores unsupported doc link syntax', () => {
expect(getMarkdownDocLinkDecorationRanges('[[doc|Label]] [[bad [target]] [[]]')).toEqual([])
})
it('ignores doc links inside inline and fenced code', () => {
expect(
getMarkdownDocLinkDecorationRanges('`[[inline]]`\n\n```md\n[[fenced]]\n```\n[[real]]')
).toEqual([
{
startLineNumber: 6,
startColumn: 1,
endLineNumber: 6,
endColumn: 9
}
])
})
})
@@ -0,0 +1,107 @@
import type { editor, IDisposable, IRange } from 'monaco-editor'
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
function getInlineCodeSpans(line: string): { start: number; end: number }[] {
const spans: { start: number; end: number }[] = []
let start = -1
for (let index = 0; index < line.length; index += 1) {
if (line[index] !== '`' || (index > 0 && line[index - 1] === '\\')) {
continue
}
if (start === -1) {
start = index
} else {
spans.push({ start, end: index + 1 })
start = -1
}
}
return spans
}
function isInsideSpan(index: number, spans: { start: number; end: number }[]): boolean {
return spans.some((span) => index >= span.start && index < span.end)
}
export function getMarkdownDocLinkDecorationRanges(content: string): IRange[] {
const ranges: IRange[] = []
let insideFence = false
content.split('\n').forEach((line, lineIndex) => {
if (/^\s*(```|~~~)/.test(line)) {
insideFence = !insideFence
return
}
if (insideFence) {
return
}
const inlineCodeSpans = getInlineCodeSpans(line)
let searchFrom = 0
while (searchFrom < line.length) {
const start = line.indexOf('[[', searchFrom)
if (start === -1) {
break
}
const end = line.indexOf(']]', start + 2)
if (end === -1) {
break
}
if (!isInsideSpan(start, inlineCodeSpans)) {
const target = getMarkdownDocLinkTarget(line.slice(start + 2, end))
if (target) {
ranges.push({
startLineNumber: lineIndex + 1,
startColumn: start + 1,
endLineNumber: lineIndex + 1,
endColumn: end + 3
})
}
}
searchFrom = end + 2
}
})
return ranges
}
export type MarkdownDocLinkDecorationController = {
refresh: () => void
dispose: () => void
}
export function createMarkdownDocLinkDecorationController(
editorInstance: editor.IStandaloneCodeEditor,
getLanguage: () => string
): MarkdownDocLinkDecorationController {
const collection = editorInstance.createDecorationsCollection()
const refresh = (): void => {
const model = editorInstance.getModel()
if (!model || getLanguage() !== 'markdown') {
collection.clear()
return
}
collection.set(
getMarkdownDocLinkDecorationRanges(model.getValue()).map((range) => ({
range,
options: {
inlineClassName: 'monaco-markdown-doc-link',
stickiness: 1
}
}))
)
}
const listener: IDisposable = editorInstance.onDidChangeModelContent(refresh)
refresh()
return {
refresh,
dispose: () => {
listener.dispose()
collection.clear()
}
}
}
@@ -1,7 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { getMarkdownDocLinkTarget } from './markdown-doc-links'
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const PLACEHOLDER_SUFFIX = ']]'
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
@@ -172,6 +174,28 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
}
// Why: doc link encoding runs inside the same while loop (not a separate
// pre-pass) so that fenced code blocks and backtick code spans are already
// skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
// sibling placeholders that were already emitted earlier in this pass.
if (
content[index] === '[' &&
content[index + 1] === '[' &&
!content.startsWith('[[ORCA_', index) &&
!isEscaped(content, index)
) {
const closingIndex = content.indexOf(']]', index + 2)
if (closingIndex !== -1) {
const rawTarget = content.slice(index + 2, closingIndex)
const target = getMarkdownDocLinkTarget(rawTarget)
if (target) {
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${target}${PLACEHOLDER_SUFFIX}`
index = closingIndex + 2
continue
}
}
}
const nextChar = content[index]
result += nextChar
isLineStart = nextChar === '\n'
@@ -1,6 +1,8 @@
import React from 'react'
import type { Editor } from '@tiptap/react'
import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
import type { MarkdownDocument } from '../../../../shared/types'
import { stripMarkdownExtension } from './markdown-doc-links'
export type SlashMenuState = {
query: string
@@ -10,6 +12,23 @@ export type SlashMenuState = {
top: number
}
export type DocLinkMenuState = {
query: string
// Why: `from` points at the first `[` of the `[[` trigger (not after the brackets)
// so the commit path's deleteRange({ from, to }) removes `[[query` entirely before
// inserting the atom node.
from: number
to: number
left: number
top: number
}
// Why: discriminated union so v2 can add non-document rows (e.g., "Create <query>")
// without refactoring the commit path. v1 only emits `document` rows.
export type DocLinkMenuRow =
| { kind: 'document'; document: MarkdownDocument }
| { kind: 'action'; id: string; label: string; run: (editor: Editor) => void }
export type SlashCommandId =
| 'text'
| 'heading-1'
@@ -221,3 +240,116 @@ export function syncSlashMenu(
top: coords.bottom - rect.top + 8
})
}
// Why: the leading alternation `(^|[\s(])` is a mid-word guard so typing `foo[[`
// inside a word does not fire the popover — `[[` only triggers at start-of-block,
// after whitespace, or after `(`. `[^[\]|\r\n]*` bans characters that would break
// the trigger contract (nested brackets) or belong to a deferred v1 feature (`|`
// display-text override).
const DOC_LINK_TRIGGER_REGEX = /(^|[\s(])\[\[([^[\]|\r\n]*)$/
/**
* Inserts an atom doc-link node at the trigger range. Directly creating the
* markdownDocLink node (instead of typing `[[target]]` and relying on the
* auto-convert plugin) keeps undo to a single step and avoids a one-tick
* flicker where the inline preview decoration would also run.
*/
export function runDocLinkCommand(
editor: Editor,
menu: { from: number; to: number },
document: MarkdownDocument
): void {
const target = stripMarkdownExtension(document.relativePath)
editor
.chain()
.focus()
.deleteRange({ from: menu.from, to: menu.to })
.insertContentAt(menu.from, { type: 'markdownDocLink', attrs: { target } })
.run()
}
/**
* Single commit entrypoint shared by click and keyboard paths. Dispatches on
* the row kind so v2 action rows plug in without refactoring either caller.
*/
export function commitRow(editor: Editor, menu: DocLinkMenuState, row: DocLinkMenuRow): void {
if (row.kind === 'document') {
runDocLinkCommand(editor, menu, row.document)
return
}
// Why: action rows (v2 "Create <query>") delete the trigger text and hand
// off to their own run(). v1 never emits action rows so this branch is dead
// today, but the shape is in place so the hook point costs nothing later.
editor.chain().focus().deleteRange({ from: menu.from, to: menu.to }).run()
row.run(editor)
}
/**
* Mirror of `syncSlashMenu` for the `[[...]]` doc-link trigger. Every bail
* path MUST call setDocLinkMenu(null) so a previously-open popover closes
* when the cursor leaves the trigger region.
*/
export function syncDocLinkMenu(
editor: Editor,
root: HTMLDivElement | null,
setDocLinkMenu: React.Dispatch<React.SetStateAction<DocLinkMenuState | null>>
): void {
// Guard 1: not editable, IME composing, or non-empty selection.
if (!root || editor.view.composing || !editor.isEditable) {
setDocLinkMenu(null)
return
}
const { state, view } = editor
const { selection } = state
if (!selection.empty) {
setDocLinkMenu(null)
return
}
// Guard 2: must be inside a textblock.
const { $from } = selection
if (!$from.parent.isTextblock) {
setDocLinkMenu(null)
return
}
// Guard 3: skip code contexts (fenced code, inline code mark). Inside code,
// `[[` is literal text and must not open the popover.
if ($from.parent.type.spec.code) {
setDocLinkMenu(null)
return
}
const codeMarkType = state.schema.marks.code
if (codeMarkType && state.doc.rangeHasMark($from.pos, $from.pos, codeMarkType)) {
setDocLinkMenu(null)
return
}
// Guard 4/5: extract block text and match the trigger regex.
const blockTextBeforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
const match = blockTextBeforeCursor.match(DOC_LINK_TRIGGER_REGEX)
if (!match) {
setDocLinkMenu(null)
return
}
// Why: use lastIndexOf('[[') for the `from` position because match.index points
// at the boundary char captured by group 1 (whitespace/`(`), not at the first
// `[`. Using match.index directly would make deleteRange({ from, to }) also
// eat the preceding whitespace/paren. `[[` cannot appear inside capture group 2
// (the negated class excludes `[`), so lastIndexOf is unambiguous.
const bracketOffset = blockTextBeforeCursor.lastIndexOf('[[')
const from = selection.from - ($from.parentOffset - bracketOffset)
const coords = view.coordsAtPos(selection.from)
const rect = root.getBoundingClientRect()
setDocLinkMenu({
query: match[2] ?? '',
from,
to: selection.from,
left: coords.left - rect.left,
top: coords.bottom - rect.top + 8
})
}
@@ -0,0 +1,313 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { MarkdownDocument } from '../../../../shared/types'
import type { MarkdownDocumentIndex } from './markdown-doc-links'
import {
createMarkdownDocumentIndex,
getMarkdownDocLinkTarget,
resolveMarkdownDocLink
} from './markdown-doc-links'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
const DOC_LINK_PATTERN = /\[\[([^[\]\r\n|]+)\]\]/g
const docLinkDissolveKey = new PluginKey('docLinkDissolve')
const docLinkAutoConvertKey = new PluginKey('docLinkAutoConvert')
const docLinkInlinePreviewKey = new PluginKey('docLinkInlinePreview')
type DocLinkStorage = {
documents: MarkdownDocument[]
_cachedDocs: MarkdownDocument[] | null
_cachedIndex: MarkdownDocumentIndex | null
}
function getDocIndex(storage: DocLinkStorage): MarkdownDocumentIndex | null {
if (storage.documents.length === 0) {
// Why: clear the cache so stale MarkdownDocument references aren't retained
// after the document list empties (e.g., when switching worktrees).
storage._cachedDocs = null
storage._cachedIndex = null
return null
}
if (storage._cachedDocs !== storage.documents) {
storage._cachedIndex = createMarkdownDocumentIndex(storage.documents)
storage._cachedDocs = storage.documents
}
return storage._cachedIndex
}
function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): DecorationSet {
const decorations: Decoration[] = []
const index = getDocIndex(storage)
const cursor = state.selection.from
state.doc.descendants((node, pos) => {
if (node.type.name !== 'text' || !node.text) {
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const target = getMarkdownDocLinkTarget(match[1])
if (!target || match.index === undefined) {
continue
}
const from = pos + match.index
const to = from + match[0].length
// Why: only decorate the match the cursor is currently editing. Other
// `[[target]]` matches are auto-converted to atom nodes on the next
// transaction, so decorating them here just causes a one-frame flicker.
if (cursor <= from || cursor > to) {
continue
}
const resolved = resolveAgainstIndex(target, index)
const cls = resolved
? 'rich-markdown-doc-link-preview'
: 'rich-markdown-doc-link-preview rich-markdown-doc-link-preview--missing'
decorations.push(Decoration.inline(from, to, { class: cls }))
}
})
return DecorationSet.create(state.doc, decorations)
}
function resolveAgainstIndex(target: string, index: MarkdownDocumentIndex | null): boolean {
if (!index) {
return false
}
return resolveMarkdownDocLink(target, index).status === 'resolved'
}
export const MarkdownDocLink = Node.create({
name: 'markdownDocLink',
inline: true,
group: 'inline',
atom: true,
selectable: true,
addStorage() {
return {
documents: [] as MarkdownDocument[],
_cachedDocs: null as MarkdownDocument[] | null,
_cachedIndex: null as MarkdownDocumentIndex | null
}
},
addAttributes() {
return {
target: {
default: '',
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
}
}
},
markdownTokenName: 'markdownDocLink',
markdownTokenizer: {
name: 'markdownDocLink',
level: 'inline',
start: DOC_LINK_PLACEHOLDER_PREFIX,
tokenize(src: string) {
if (!src.startsWith(DOC_LINK_PLACEHOLDER_PREFIX)) {
return undefined
}
const endIndex = src.indexOf(DOC_LINK_PLACEHOLDER_SUFFIX, DOC_LINK_PLACEHOLDER_PREFIX.length)
if (endIndex === -1) {
return undefined
}
const placeholder = src.slice(0, endIndex + DOC_LINK_PLACEHOLDER_SUFFIX.length)
const target = src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex)
return {
type: 'markdownDocLink',
raw: placeholder,
text: target
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'markdownDocLink') {
return []
}
return helpers.createNode('markdownDocLink', {
target: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) =>
`[[${typeof node.attrs?.target === 'string' ? node.attrs.target : ''}]]`,
addNodeView() {
const storage = this.storage as DocLinkStorage
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
const getTarget = (n: { attrs: Record<string, unknown> }): string =>
typeof n.attrs.target === 'string' ? n.attrs.target : ''
const target = getTarget(node)
const dom = document.createElement('span')
dom.setAttribute('data-doc-link-target', target)
dom.setAttribute('contenteditable', 'false')
dom.textContent = target
const applyResolutionClass = (t: string): void => {
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
dom.className = resolved
? 'rich-markdown-doc-link'
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
}
applyResolutionClass(target)
return {
dom,
// Why: this fires on every transaction, including the no-op dispatched
// when the document list changes in storage. Re-checking resolution
// here keeps the blue/grey styling current without a full re-render.
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
if (updatedNode.type.name !== 'markdownDocLink') {
return false
}
const newTarget = getTarget(updatedNode)
dom.setAttribute('data-doc-link-target', newTarget)
dom.textContent = newTarget
applyResolutionClass(newTarget)
return true
}
}
}
},
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
// any order — brackets first then target, paste, etc. — converts to a doc
// link node. Input rules only fire on sequential append at the cursor.
addProseMirrorPlugins() {
const nodeType = this.type
const storage = this.storage as DocLinkStorage
return [
// Why: when the cursor is adjacent to a doc link atom and the user presses
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
// Without this, atom nodes are un-enterable — the cursor jumps over them.
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
// keys and let Shift+Arrow extend the selection normally.
new Plugin({
key: docLinkDissolveKey,
props: {
handleKeyDown(view, event) {
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
return false
}
let direction: 'left' | 'right'
if (event.key === 'ArrowLeft') {
direction = 'left'
} else if (event.key === 'ArrowRight') {
direction = 'right'
} else {
return false
}
const { state } = view
if (!(state.selection instanceof TextSelection)) {
return false
}
const { $from } = state.selection
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
return false
}
const target = typeof adjacent.attrs.target === 'string' ? adjacent.attrs.target : ''
const text = `[[${target}]]`
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
const nodeEnd = nodeStart + adjacent.nodeSize
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
view.dispatch(tr)
return true
}
}
}),
new Plugin({
key: docLinkAutoConvertKey,
appendTransaction(_transactions, _oldState, newState) {
const { tr } = newState
const cursor = newState.selection.from
let modified = false
newState.doc.descendants((node, pos) => {
if (node.type.name !== 'text' || !node.text) {
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const target = getMarkdownDocLinkTarget(match[1])
if (!target || match.index === undefined) {
continue
}
const from = pos + match.index
const to = from + match[0].length
// Why: skip when the cursor is anywhere from just inside [[
// through the closing ]]. The inline preview decoration gives
// real-time resolution feedback while the user is still editing.
if (cursor > from && cursor <= to) {
continue
}
const docLinkNode = nodeType.create({ target })
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
modified = true
}
})
return modified ? tr : null
}
}),
// Why: while the cursor is inside [[target]], the text hasn't converted
// to an atom node yet. This decoration gives real-time blue/grey feedback
// so the user knows whether the target resolves before moving the cursor out.
new Plugin({
key: docLinkInlinePreviewKey,
state: {
init(_, state) {
return buildPreviewDecorations(state, storage)
},
apply(tr, prev, oldState, newState) {
const selectionMoved = !oldState.selection.eq(newState.selection)
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
return prev
}
return buildPreviewDecorations(newState, storage)
}
},
props: {
decorations(state) {
return docLinkInlinePreviewKey.getState(state)
}
}
})
]
},
parseHTML() {
return [{ tag: 'span[data-doc-link-target]' }]
},
renderHTML({ HTMLAttributes, node }) {
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-doc-link-target': target,
contenteditable: 'false',
class: 'rich-markdown-doc-link'
}),
target
]
}
})
@@ -14,6 +14,7 @@ import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
import { MarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
import { DragSelectionGuard } from './drag-selection-guard'
@@ -133,6 +134,7 @@ export function createRichMarkdownExtensions({
TableCell,
RawMarkdownHtmlInline,
RawMarkdownHtmlBlock,
MarkdownDocLink,
DragSelectionGuard,
Markdown.configure({
markedOptions: {
@@ -2,7 +2,14 @@ import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import { runSlashCommand, type SlashCommand, type SlashMenuState } from './rich-markdown-commands'
import {
commitRow,
runSlashCommand,
type DocLinkMenuRow,
type DocLinkMenuState,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-commands'
export type KeyHandlerContext = {
isMac: boolean
@@ -15,13 +22,18 @@ export type KeyHandlerContext = {
slashMenuRef: MutableRefObject<SlashMenuState | null>
filteredSlashCommandsRef: MutableRefObject<SlashCommand[]>
selectedCommandIndexRef: MutableRefObject<number>
docLinkMenuRef: MutableRefObject<DocLinkMenuState | null>
filteredDocLinkRowsRef: MutableRefObject<DocLinkMenuRow[]>
selectedDocLinkIndexRef: MutableRefObject<number>
handleLocalImagePickRef: MutableRefObject<() => void>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
setIsEditingLink: (editing: boolean) => void
setLinkBubble: (bubble: LinkBubbleState | null) => void
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: (menu: SlashMenuState | null) => void
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
}
/**
@@ -85,10 +97,10 @@ export function createRichMarkdownKeyHandler(
}
// Tab/Shift-Tab: indent/outdent lists, insert spaces in code blocks,
// and prevent focus from escaping the editor. When the slash menu is
// open, Tab selects a command instead (handled in the slash-menu block
// below).
if (event.key === 'Tab' && !ctx.slashMenuRef.current) {
// and prevent focus from escaping the editor. When the slash menu or
// doc-link menu is open, Tab selects a row instead (handled in the
// menu blocks below).
if (event.key === 'Tab' && !ctx.slashMenuRef.current && !ctx.docLinkMenuRef.current) {
event.preventDefault()
const ed = ctx.editorRef.current
if (!ed) {
@@ -115,6 +127,67 @@ export function createRichMarkdownKeyHandler(
return true
}
// ── Doc-link menu navigation ──────────────────────
// Why: this block MUST be registered before the slash-menu block below.
// The slash-menu block early-returns with `return false` when no slash
// menu is open, which short-circuits every subsequent handler — a
// doc-link block placed after it would be dead code. When THIS menu is
// closed, fall through (no early return) so the slash-menu block below
// still gets a chance.
const currentDocLinkMenu = ctx.docLinkMenuRef.current
if (currentDocLinkMenu) {
const currentFilteredDocLinkRows = ctx.filteredDocLinkRowsRef.current
const activeEditorForDocLink = ctx.editorRef.current
if (event.key === 'ArrowDown') {
if (currentFilteredDocLinkRows.length === 0) {
return false
}
event.preventDefault()
ctx.setSelectedDocLinkIndex(
(currentIndex) => (currentIndex + 1) % currentFilteredDocLinkRows.length
)
return true
}
if (event.key === 'ArrowUp') {
if (currentFilteredDocLinkRows.length === 0) {
return false
}
event.preventDefault()
ctx.setSelectedDocLinkIndex(
(currentIndex) =>
(currentIndex - 1 + currentFilteredDocLinkRows.length) %
currentFilteredDocLinkRows.length
)
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
// Why: with zero rows (empty state), Enter must fall through so it
// behaves as a normal paragraph break instead of silently eating the
// keystroke. syncDocLinkMenu closes the popover on the next tick.
if (currentFilteredDocLinkRows.length === 0 || !activeEditorForDocLink) {
return false
}
event.preventDefault()
const selectedRow =
currentFilteredDocLinkRows[ctx.selectedDocLinkIndexRef.current] ??
currentFilteredDocLinkRows[0]
if (selectedRow) {
commitRow(activeEditorForDocLink, currentDocLinkMenu, selectedRow)
}
return true
}
if (event.key === 'Escape') {
event.preventDefault()
ctx.setDocLinkMenu(null)
return true
}
// Any other key (including ArrowLeft/ArrowRight, Backspace, printable
// characters) falls through. syncDocLinkMenu re-runs on the next
// onUpdate/onSelectionUpdate and closes or refreshes the popover based
// on whether the trigger still matches.
}
// ── Slash menu navigation ─────────────────────────
const currentSlashMenu = ctx.slashMenuRef.current
if (!currentSlashMenu) {
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { getConnectionId } from '@/lib/connection-context'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import { createMarkdownDocumentIndex, resolveMarkdownDocLink } from './markdown-doc-links'
type UseMarkdownDocumentsResult = {
markdownDocuments: MarkdownDocument[]
openMarkdownDocument: (document: MarkdownDocument) => Promise<void>
onOpenDocLink: (target: string) => void
previewProps: {
markdownDocuments: MarkdownDocument[]
onOpenDocument: (document: MarkdownDocument) => Promise<void>
}
mdSave: (content: string) => Promise<void>
}
export function useMarkdownDocuments(
activeFile: OpenFile,
isMarkdown: boolean,
viewMode: MarkdownViewMode,
onSave: (content: string) => Promise<void>
): UseMarkdownDocumentsResult {
const worktreeId = activeFile.worktreeId
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const openFile = useAppStore((s) => s.openFile)
const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState<
Record<string, MarkdownDocument[]>
>({})
const requestRef = useRef(0)
const worktreePath = useMemo(() => {
if (!worktreeId) {
return null
}
return findWorktreeById(worktreesByRepo, worktreeId)?.path ?? null
}, [worktreeId, worktreesByRepo])
const connectionId = getConnectionId(worktreeId)
const refreshMarkdownDocuments = useCallback(async (): Promise<void> => {
if (!worktreeId || !worktreePath) {
return
}
const requestId = requestRef.current + 1
requestRef.current = requestId
try {
const documents = await window.api.fs.listMarkdownDocuments({
rootPath: worktreePath,
connectionId: connectionId ?? undefined
})
if (requestRef.current !== requestId) {
return
}
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: documents
}))
} catch (err) {
console.error('Failed to list markdown documents:', err)
if (requestRef.current === requestId) {
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: []
}))
}
}
}, [connectionId, worktreeId, worktreePath])
const openMarkdownDocument = useCallback(
async (document: MarkdownDocument): Promise<void> => {
if (!worktreeId) {
return
}
try {
const stats = await window.api.fs.stat({
filePath: document.filePath,
connectionId: connectionId ?? undefined
})
if (stats.isDirectory) {
await refreshMarkdownDocuments()
return
}
} catch {
await refreshMarkdownDocuments()
return
}
openFile({
filePath: document.filePath,
relativePath: document.relativePath,
worktreeId,
language: 'markdown',
mode: 'edit'
})
},
[connectionId, openFile, refreshMarkdownDocuments, worktreeId]
)
useEffect(() => {
if (!isMarkdown) {
return
}
void refreshMarkdownDocuments()
}, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments])
const markdownDocuments = useMemo(
() => (worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : []),
[worktreeId, markdownDocumentsByWorktree]
)
const previewProps = useMemo(
() => ({ markdownDocuments, onOpenDocument: openMarkdownDocument }),
[markdownDocuments, openMarkdownDocument]
)
const mdSave = useCallback(
(content: string) => onSave(content).then(() => refreshMarkdownDocuments()),
[onSave, refreshMarkdownDocuments]
)
const docIndex = useMemo(
() => createMarkdownDocumentIndex(markdownDocuments),
[markdownDocuments]
)
const onOpenDocLink = useCallback(
(target: string) => {
const resolution = resolveMarkdownDocLink(target, docIndex)
if (resolution.status === 'resolved') {
void openMarkdownDocument(resolution.document)
}
},
[docIndex, openMarkdownDocument]
)
return { markdownDocuments, openMarkdownDocument, onOpenDocLink, previewProps, mdSave }
}
@@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import { BaseRefPicker } from './BaseRefPicker'
import { RepositoryHooksSection } from './RepositoryHooksSection'
import { SparsePresetSettingsSection } from './SparsePresetSettingsSection'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { useAppStore } from '../../store'
@@ -43,6 +44,20 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
title: 'Default Worktree Base',
description: 'Default base branch or ref when creating worktrees.',
keywords: [repo.displayName, 'base ref', 'branch']
},
{
title: 'Sparse Checkout Presets',
description: 'Saved directory sets for sparse worktree creation.',
keywords: [
repo.displayName,
'sparse',
'checkout',
'preset',
'presets',
'directory',
'directories',
'monorepo'
]
}
]),
{
@@ -164,6 +179,9 @@ export function RepositoryPane({
const identityEntries = allEntries.filter((entry) =>
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
)
const sparsePresetEntries = allEntries.filter((entry) =>
['Sparse Checkout Presets'].includes(entry.title)
)
const hooksEntries = allEntries.filter((entry) =>
[
'orca.yaml hooks',
@@ -269,6 +287,9 @@ export function RepositoryPane({
) : null}
</section>
) : null,
!isFolder && matchesSettingsSearch(searchQuery, sparsePresetEntries) ? (
<SparsePresetSettingsSection key="sparse-presets" repoId={repo.id} />
) : null,
!isFolder && matchesSettingsSearch(searchQuery, hooksEntries) ? (
<RepositoryHooksSection
key="hooks"
@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react'
import { Bookmark, LoaderCircle, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
import type { SparsePreset } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { cn } from '@/lib/utils'
import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { formatSparsePresetUpdatedAt } from './sparse-preset-date'
type SparsePresetSettingsSectionProps = {
repoId: string
}
type SparsePresetDraft = {
mode: 'new' | 'edit'
presetId?: string
name: string
directoriesText: string
}
function SparsePresetDirectoryPreview({
directories
}: {
directories: string[]
}): React.JSX.Element {
const visibleDirectories = directories.slice(0, 6)
const hiddenCount = directories.length - visibleDirectories.length
return (
<div className="flex flex-wrap gap-1.5">
{visibleDirectories.map((directory) => (
<span
key={directory}
className="min-w-0 max-w-full truncate rounded-md border border-border/50 bg-muted/35 px-2 py-1 font-mono text-[11px] text-foreground/80"
title={directory}
>
{directory}
</span>
))}
{hiddenCount > 0 ? (
<span className="rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground">
+{hiddenCount} more
</span>
) : null}
</div>
)
}
export function SparsePresetSettingsSection({
repoId
}: SparsePresetSettingsSectionProps): React.JSX.Element {
const presets = useAppStore((s) => s.sparsePresetsByRepo[repoId])
const fetchSparsePresets = useAppStore((s) => s.fetchSparsePresets)
const saveSparsePreset = useAppStore((s) => s.saveSparsePreset)
const removeSparsePreset = useAppStore((s) => s.removeSparsePreset)
const [draft, setDraft] = useState<SparsePresetDraft | null>(null)
const [submitting, setSubmitting] = useState(false)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
useEffect(() => {
if (presets === undefined) {
void fetchSparsePresets(repoId)
}
}, [fetchSparsePresets, presets, repoId])
const sortedPresets = presets ?? []
const parsedDirectories = draft ? parseSparsePresetDirectories(draft.directoriesText) : null
const trimmedName = draft?.name.trim() ?? ''
const lowerName = trimmedName.toLowerCase()
const collidingPreset =
draft && trimmedName
? (sortedPresets.find(
(preset) => preset.id !== draft.presetId && preset.name.toLowerCase() === lowerName
) ?? null)
: null
const nameError =
draft && trimmedName.length === 0
? 'Name is required.'
: trimmedName.length > 80
? 'Name must be 80 characters or fewer.'
: collidingPreset
? `"${collidingPreset.name}" already exists.`
: null
const canSaveDraft =
!!draft && !submitting && !nameError && parsedDirectories !== null && !parsedDirectories.error
const startNewPreset = (): void => {
setConfirmingDeleteId(null)
setDraft({
mode: 'new',
name: '',
directoriesText: ''
})
}
const startEditPreset = (preset: SparsePreset): void => {
setConfirmingDeleteId(null)
setDraft({
mode: 'edit',
presetId: preset.id,
name: preset.name,
directoriesText: preset.directories.join('\n')
})
}
const handleSaveDraft = async (): Promise<void> => {
if (!draft || !canSaveDraft || !parsedDirectories) {
return
}
setSubmitting(true)
try {
const saved = await saveSparsePreset({
repoId,
id: draft.presetId,
name: trimmedName,
directories: parsedDirectories.directories
})
if (saved) {
setDraft(null)
}
} finally {
setSubmitting(false)
}
}
const handleDeletePreset = async (preset: SparsePreset): Promise<void> => {
if (confirmingDeleteId !== preset.id) {
setConfirmingDeleteId(preset.id)
return
}
if (draft?.presetId === preset.id) {
setDraft(null)
}
setConfirmingDeleteId(null)
await removeSparsePreset({ repoId, presetId: preset.id })
}
const renderDraftEditor = (): React.JSX.Element | null => {
if (!draft) {
return null
}
return (
<div className="rounded-xl border border-border/60 bg-background/80 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="space-y-0.5">
<h5 className="text-sm font-semibold">
{draft.mode === 'new' ? 'New Preset' : 'Edit Preset'}
</h5>
<p className="text-xs text-muted-foreground">
Saved directories are used when creating sparse worktrees for this repository.
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Cancel preset edit"
onClick={() => setDraft(null)}
disabled={submitting}
>
<X className="size-3.5" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="space-y-2">
<Label htmlFor="sparse-preset-settings-name">Name</Label>
<Input
id="sparse-preset-settings-name"
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="e.g. web-only"
maxLength={80}
autoComplete="off"
spellCheck={false}
className="h-9 text-sm"
/>
{nameError ? <p className="text-xs text-destructive">{nameError}</p> : null}
</div>
<div className="space-y-2">
<Label htmlFor="sparse-preset-settings-directories">Directories</Label>
<textarea
id="sparse-preset-settings-directories"
value={draft.directoriesText}
onChange={(event) => setDraft({ ...draft, directoriesText: event.target.value })}
placeholder={`packages/web\nshared/ui`}
rows={5}
spellCheck={false}
className="w-full min-w-0 resize-y rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
{parsedDirectories?.error ? (
<p className="text-xs text-destructive">{parsedDirectories.error}</p>
) : (
<p className="text-xs text-muted-foreground">
{parsedDirectories?.directories.length === 1
? '1 directory will be saved.'
: `${parsedDirectories?.directories.length ?? 0} directories will be saved.`}{' '}
Use repo-relative paths like packages/web or apps/api.
</p>
)}
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setDraft(null)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleSaveDraft()}
disabled={!canSaveDraft}
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
Save Preset
</Button>
</div>
</div>
)
}
return (
<section className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Sparse Checkout Presets</h3>
<p className="text-xs text-muted-foreground">
Manage saved directory sets for sparse worktree creation.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={startNewPreset}
disabled={!!draft}
>
<Plus className="size-3.5" />
New Preset
</Button>
</div>
{renderDraftEditor()}
{presets === undefined ? (
<div className="rounded-xl border border-dashed border-border/60 bg-background/60 px-4 py-6 text-sm text-muted-foreground">
Loading sparse presets...
</div>
) : sortedPresets.length === 0 && !draft ? (
<div className="rounded-xl border border-dashed border-border/60 bg-background/60 px-4 py-6 text-sm text-muted-foreground">
No sparse presets saved for this repository.
</div>
) : (
<div className="space-y-2">
{sortedPresets.map((preset) => {
// Why: users can already have locally persisted presets from older
// builds or hand-edited state; a bad timestamp must not blank Settings.
const updatedLabel = formatSparsePresetUpdatedAt(preset.updatedAt)
return (
<div
key={preset.id}
className="rounded-xl border border-border/50 bg-background/70 px-4 py-3 shadow-sm"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-muted/30">
<Bookmark className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h4 className="min-w-0 truncate text-sm font-medium">{preset.name}</h4>
<span className="text-[11px] text-muted-foreground">
{preset.directories.length === 1
? '1 directory'
: `${preset.directories.length} directories`}
</span>
<span className="text-[11px] text-muted-foreground">
{updatedLabel ? `Updated ${updatedLabel}` : 'Updated date unknown'}
</span>
</div>
<SparsePresetDirectoryPreview directories={preset.directories} />
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${preset.name}`}
onClick={() => startEditPreset(preset)}
disabled={submitting}
>
<Pencil className="size-3.5" />
</Button>
<Button
type="button"
variant={confirmingDeleteId === preset.id ? 'destructive' : 'ghost'}
size="sm"
aria-label={`Delete ${preset.name}`}
onClick={() => void handleDeletePreset(preset)}
onBlur={() => setConfirmingDeleteId(null)}
disabled={submitting}
className={cn(
'w-[5.75rem] px-2 text-xs',
confirmingDeleteId !== preset.id && 'text-muted-foreground'
)}
>
<Trash2 className="size-3.5" />
{confirmingDeleteId === preset.id ? 'Confirm' : 'Delete'}
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
)
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { formatSparsePresetUpdatedAt } from './sparse-preset-date'
describe('formatSparsePresetUpdatedAt', () => {
it('formats valid timestamps', () => {
expect(formatSparsePresetUpdatedAt(Date.UTC(2026, 0, 2))).toContain('2026')
})
it('returns null for invalid persisted timestamps', () => {
expect(formatSparsePresetUpdatedAt(Number.NaN)).toBeNull()
expect(formatSparsePresetUpdatedAt(Number.POSITIVE_INFINITY)).toBeNull()
})
})
@@ -0,0 +1,16 @@
export function formatSparsePresetUpdatedAt(timestamp: number): string | null {
if (!Number.isFinite(timestamp)) {
return null
}
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) {
return null
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date)
}
@@ -1,181 +0,0 @@
import React, { useCallback } from 'react'
import { Search, X, Activity, ListFilter, FolderPlus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
const SearchBar = React.memo(function SearchBar() {
const searchQuery = useAppStore((s) => s.searchQuery)
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const repos = useAppStore((s) => s.repos)
const addRepo = useAppStore((s) => s.addRepo)
const selectedRepos = repos.filter((r) => filterRepoIds.includes(r.id))
const handleToggleRepo = useCallback(
(repoId: string) => {
setFilterRepoIds(
filterRepoIds.includes(repoId)
? filterRepoIds.filter((id) => id !== repoId)
: [...filterRepoIds, repoId]
)
},
[filterRepoIds, setFilterRepoIds]
)
const handleClear = useCallback(() => setSearchQuery(''), [setSearchQuery])
const handleToggleActive = useCallback(
() => setShowActiveOnly(!showActiveOnly),
[showActiveOnly, setShowActiveOnly]
)
const canFilterRepos = repos.length > 1
const hasRepoFilter = canFilterRepos && selectedRepos.length > 0
const hasAnyFilter = showActiveOnly || hasRepoFilter
const activeFilterCount = (showActiveOnly ? 1 : 0) + (hasRepoFilter ? selectedRepos.length : 0)
const filterSummary = (() => {
if (!hasAnyFilter) {
return null
}
if (showActiveOnly && !hasRepoFilter) {
return (
<span className="flex items-center gap-1">
<Activity className="size-3" />
<span>Active</span>
</span>
)
}
if (!showActiveOnly && hasRepoFilter && selectedRepos.length === 1) {
return (
<RepoDotLabel
name={selectedRepos[0].displayName}
color={selectedRepos[0].badgeColor}
dotClassName="size-1"
/>
)
}
return <span>{activeFilterCount} filters</span>
})()
return (
<div className="px-2 pb-1">
<div className="relative flex items-center">
<Search
className="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none"
strokeWidth={2.25}
/>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="h-7 pl-7 pr-20 text-[12px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30 placeholder:text-muted-foreground/70"
/>
<div className="absolute right-1 flex items-center gap-0.5">
{searchQuery && (
<Button variant="ghost" size="icon-xs" onClick={handleClear} className="size-5">
<X className="size-3" />
</Button>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
type="button"
aria-label="Filter worktrees"
className={cn(
'h-5 gap-1 border-none px-1 text-[10px] font-normal shadow-none focus-visible:ring-0',
hasAnyFilter
? 'w-auto bg-accent text-accent-foreground hover:bg-accent/80'
: 'size-5 w-5 bg-transparent hover:bg-accent/60'
)}
>
<ListFilter className="size-3 shrink-0" strokeWidth={2.25} />
{filterSummary}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{hasAnyFilter ? 'Edit filters' : 'Filter worktrees'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showActiveOnly}
onCheckedChange={handleToggleActive}
onSelect={(event) => event.preventDefault()}
>
<Activity className="size-3.5 text-muted-foreground" />
Active only
</DropdownMenuCheckboxItem>
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
{repos.map((r) => (
<DropdownMenuCheckboxItem
key={r.id}
checked={filterRepoIds.includes(r.id)}
onCheckedChange={() => handleToggleRepo(r.id)}
onSelect={(event) => event.preventDefault()}
>
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
</DropdownMenuCheckboxItem>
))}
</>
)}
{hasAnyFilter && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setShowActiveOnly(false)
setFilterRepoIds([])
}}
>
<X className="size-3.5 text-muted-foreground" />
Clear filters
</DropdownMenuItem>
</>
)}
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
inset
onSelect={() => {
addRepo()
}}
>
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
Add project
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})
export default SearchBar
@@ -0,0 +1,140 @@
import React, { useCallback } from 'react'
import { Activity, ListFilter, FolderPlus, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
const SidebarFilter = React.memo(function SidebarFilter() {
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const repos = useAppStore((s) => s.repos)
const addRepo = useAppStore((s) => s.addRepo)
const handleToggleRepo = useCallback(
(repoId: string) => {
setFilterRepoIds(
filterRepoIds.includes(repoId)
? filterRepoIds.filter((id) => id !== repoId)
: [...filterRepoIds, repoId]
)
},
[filterRepoIds, setFilterRepoIds]
)
const handleToggleActive = useCallback(
() => setShowActiveOnly(!showActiveOnly),
[showActiveOnly, setShowActiveOnly]
)
const canFilterRepos = repos.length > 1
// Why: derive from the current repos list so stale IDs in filterRepoIds
// (e.g. lingering after a repo is removed) don't inflate the active-filter
// count or falsely signal an applied filter.
const selectedRepos = canFilterRepos ? repos.filter((r) => filterRepoIds.includes(r.id)) : []
const hasRepoFilter = selectedRepos.length > 0
const hasAnyFilter = showActiveOnly || hasRepoFilter
const activeFilterCount = (showActiveOnly ? 1 : 0) + selectedRepos.length
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
type="button"
aria-label={
hasAnyFilter ? `Edit filters (${activeFilterCount} active)` : 'Filter workspaces'
}
className="relative text-muted-foreground"
>
<ListFilter className="size-3.5" strokeWidth={2.25} />
{hasAnyFilter && (
// Why: the only at-a-glance affordance that filters are
// applied — without it the list can silently hide workspaces.
<span
aria-hidden
className="absolute -top-0.5 -right-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-medium leading-none text-primary-foreground"
>
{activeFilterCount > 9 ? '9+' : activeFilterCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{hasAnyFilter ? 'Edit filters' : 'Filter workspaces'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showActiveOnly}
onCheckedChange={handleToggleActive}
onSelect={(event) => event.preventDefault()}
>
<Activity className="size-3.5 text-muted-foreground" />
Active only
</DropdownMenuCheckboxItem>
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
{repos.map((r) => (
<DropdownMenuCheckboxItem
key={r.id}
checked={filterRepoIds.includes(r.id)}
onCheckedChange={() => handleToggleRepo(r.id)}
onSelect={(event) => event.preventDefault()}
>
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
</DropdownMenuCheckboxItem>
))}
</>
)}
{hasAnyFilter && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setShowActiveOnly(false)
setFilterRepoIds([])
}}
>
<X className="size-3.5 text-muted-foreground" />
Clear filters
</DropdownMenuItem>
</>
)}
{canFilterRepos && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
inset
onSelect={() => {
addRepo()
}}
>
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
Add project
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
export default SidebarFilter
@@ -16,6 +16,7 @@ import {
DropdownMenuRadioItem
} from '@/components/ui/dropdown-menu'
import type { WorktreeCardProperty } from '../../../../shared/types'
import SidebarFilter from './SidebarFilter'
const GROUP_BY_OPTIONS = [
{ id: 'none', label: 'All' },
@@ -67,11 +68,12 @@ const SidebarHeader = React.memo(function SidebarHeader() {
: PROPERTY_OPTIONS.filter((opt) => opt.id !== 'inline-agents')
return (
<div className="flex h-8 items-center justify-between px-2 mt-1 gap-2">
<div className="flex h-8 items-center justify-between px-2 gap-2">
<span className="px-2 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
Workspaces
</span>
<div className="flex items-center gap-1.5 shrink-0">
<SidebarFilter />
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -40,6 +40,11 @@ type WorktreeCardProps = {
hintNumber?: number
}
function formatSparseDirectoryPreview(directories: string[]): string {
const preview = directories.slice(0, 4).join(', ')
return directories.length <= 4 ? preview : `${preview}, +${directories.length - 4} more`
}
const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
@@ -445,6 +450,29 @@ const WorktreeCard = React.memo(function WorktreeCard({
</TooltipContent>
</Tooltip>
)}
{worktree.isSparse && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-amber-700 dark:text-amber-300 border-amber-500/30 bg-amber-500/5"
>
sparse
</Badge>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="max-w-72">
<div className="space-y-1">
<div>Partial checkout. Files outside these paths are not on disk.</div>
{worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? (
<div className="font-mono text-[11px] opacity-80">
{formatSparseDirectoryPreview(worktree.sparseDirectories)}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
{/* CI Checks & PR state on the right */}
@@ -324,9 +324,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
tabIndex={0}
className={cn(
'group flex h-7 w-full items-center gap-1.5 px-1.5 text-left transition-all cursor-pointer',
// First header sits right under the sidebar search bar, which
// already supplies its own spacing — only offset secondary
// group headers.
// First header sits directly under SidebarHeader, which already
// supplies its own spacing — only offset secondary group headers.
vItem.index !== firstHeaderIndex && 'mt-2',
row.repo ? 'overflow-hidden' : row.tone
)}
@@ -436,7 +435,6 @@ const WorktreeList = React.memo(function WorktreeList() {
const worktreeMap = useWorktreeMap()
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const searchQuery = useAppStore((s) => s.searchQuery)
const groupBy = useAppStore((s) => s.groupBy)
const sortBy = useAppStore((s) => s.sortBy)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
@@ -456,15 +454,11 @@ const WorktreeList = React.memo(function WorktreeList() {
const cardProps = useAppStore((s) => s.worktreeCardProperties)
// PR cache is needed for PR-status grouping, smart sorting, search,
// and when the PR card property is visible.
// PR cache is needed for PR-status grouping, smart sorting, and when the
// PR card property is visible.
const prCache = useAppStore((s) =>
groupBy === 'pr-status' || sortBy === 'smart' || searchQuery || cardProps.includes('pr')
? s.prCache
: null
groupBy === 'pr-status' || sortBy === 'smart' || cardProps.includes('pr') ? s.prCache : null
)
// Subscribe to issue cache only during active search to avoid unnecessary re-renders.
const issueCache = useAppStore((s) => (searchQuery ? s.issueCache : null))
const sortEpoch = useAppStore((s) => s.sortEpoch)
@@ -632,27 +626,21 @@ const WorktreeList = React.memo(function WorktreeList() {
const visibleWorktrees = useMemo(() => {
const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, {
filterRepoIds,
searchQuery,
showActiveOnly,
tabsByWorktree,
browserTabsByWorktree,
activeWorktreeId,
repoMap,
prCache,
issueCache
repoMap
})
return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null)
}, [
filterRepoIds,
searchQuery,
showActiveOnly,
activeWorktreeId,
repoMap,
tabsByWorktree,
browserTabsByWorktree,
sortedIds,
prCache,
issueCache,
worktreeMap,
worktreesByRepo
])
@@ -731,16 +719,14 @@ const WorktreeList = React.memo(function WorktreeList() {
[openModal]
)
const hasFilters = !!(searchQuery || showActiveOnly || filterRepoIds.length)
const setSearchQuery = useAppStore((s) => s.setSearchQuery)
const hasFilters = !!(showActiveOnly || filterRepoIds.length)
const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly)
const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds)
const clearFilters = useCallback(() => {
setSearchQuery('')
setShowActiveOnly(false)
setFilterRepoIds([])
}, [setSearchQuery, setShowActiveOnly, setFilterRepoIds])
}, [setShowActiveOnly, setFilterRepoIds])
if (worktrees.length === 0) {
return (
@@ -4,7 +4,6 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import SidebarHeader from './SidebarHeader'
import SidebarNav from './SidebarNav'
import SearchBar from './SearchBar'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorktreeMetaDialog from './WorktreeMetaDialog'
@@ -50,7 +49,6 @@ function Sidebar(): React.JSX.Element {
{/* Fixed controls */}
<SidebarNav />
<SidebarHeader />
<SearchBar />
<WorktreeList />
@@ -43,14 +43,11 @@ describe('computeVisibleWorktreeIds', () => {
const result = computeVisibleWorktreeIds({ repo1: [wt] }, [wt.id], {
filterRepoIds: [],
searchQuery: '',
showActiveOnly: true,
tabsByWorktree: {},
browserTabsByWorktree: { [wt.id]: [{ id: 'browser-1' }] },
activeWorktreeId: null,
repoMap,
prCache: null,
issueCache: null
repoMap
})
expect(result).toEqual([wt.id])
@@ -61,14 +58,11 @@ describe('computeVisibleWorktreeIds', () => {
const result = computeVisibleWorktreeIds({ repo1: [wt] }, [wt.id], {
filterRepoIds: [],
searchQuery: '',
showActiveOnly: true,
tabsByWorktree: {},
browserTabsByWorktree: {},
activeWorktreeId: wt.id,
repoMap,
prCache: null,
issueCache: null
repoMap
})
expect(result).toEqual([wt.id])
@@ -1,6 +1,4 @@
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
import type { AppState } from '@/store/types'
import { matchesSearch } from './worktree-list-groups'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors'
@@ -20,14 +18,11 @@ export function computeVisibleWorktreeIds(
sortedIds: string[],
opts: {
filterRepoIds: string[]
searchQuery: string
showActiveOnly: boolean
tabsByWorktree: Record<string, TerminalTab[]> | null
browserTabsByWorktree?: Record<string, { id: string }[]> | null
activeWorktreeId?: string | null
repoMap: Map<string, Repo>
prCache: AppState['prCache'] | null
issueCache: AppState['issueCache'] | null
}
): string[] {
let all: Worktree[] = getAllWorktreesFromState({ worktreesByRepo })
@@ -41,13 +36,6 @@ export function computeVisibleWorktreeIds(
all = all.filter((w) => selectedRepoIds.has(w.repoId))
}
// Filter by search — matches against displayName, branch, repo, comment,
// PR number/title, and issue number/title (see matchesSearch).
if (opts.searchQuery) {
const q = opts.searchQuery.toLowerCase()
all = all.filter((w) => matchesSearch(w, q, opts.repoMap, opts.prCache, opts.issueCache))
}
// Filter active only
if (opts.showActiveOnly) {
all = all.filter((w) => {
@@ -149,13 +137,10 @@ export function getVisibleWorktreeIds(): string[] {
return computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, {
filterRepoIds: state.filterRepoIds,
searchQuery: state.searchQuery,
showActiveOnly: state.showActiveOnly,
tabsByWorktree: state.tabsByWorktree,
browserTabsByWorktree: state.browserTabsByWorktree,
activeWorktreeId: state.activeWorktreeId,
repoMap,
prCache: state.prCache,
issueCache: state.issueCache
repoMap
})
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildRows, getPRGroupKey, matchesSearch } from './worktree-list-groups'
import { buildRows, getPRGroupKey } from './worktree-list-groups'
import type { Repo, Worktree } from '../../../../shared/types'
const repo: Repo = {
@@ -44,112 +44,6 @@ describe('getPRGroupKey', () => {
})
})
describe('matchesSearch', () => {
it('matches on displayName', () => {
expect(matchesSearch(worktree, 'super-critical', repoMap, null, null)).toBe(true)
})
it('matches on branch name', () => {
expect(matchesSearch(worktree, 'feature/super', repoMap, null, null)).toBe(true)
})
it('matches on repo displayName', () => {
expect(matchesSearch(worktree, 'orca', repoMap, null, null)).toBe(true)
})
it('matches on comment', () => {
const w = { ...worktree, comment: 'Fix the login bug' }
expect(matchesSearch(w, 'login', repoMap, null, null)).toBe(true)
})
it('does not match empty comment', () => {
expect(matchesSearch(worktree, 'login', repoMap, null, null)).toBe(false)
})
it('matches on PR number from cache', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
data: { number: 304, title: 'Add search enhancements' }
}
}
expect(matchesSearch(worktree, '304', repoMap, prCache, null)).toBe(true)
})
it('matches on PR title from cache', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
data: { number: 304, title: 'Add search enhancements' }
}
}
expect(matchesSearch(worktree, 'search enhance', repoMap, prCache, null)).toBe(true)
})
it('matches on linkedPR when cache is empty', () => {
const w = { ...worktree, linkedPR: 42 }
expect(matchesSearch(w, '42', repoMap, null, null)).toBe(true)
})
it('matches on issue number from linkedIssue', () => {
const w = { ...worktree, linkedIssue: 99 }
expect(matchesSearch(w, '99', repoMap, null, null)).toBe(true)
})
it('matches on issue title from cache', () => {
const w = { ...worktree, linkedIssue: 99 }
const issueCache = {
'/tmp/orca::99': {
data: { number: 99, title: 'Sidebar performance regression' }
}
}
expect(matchesSearch(w, 'sidebar perf', repoMap, null, issueCache)).toBe(true)
})
it('strips # prefix for number matching', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
data: { number: 304, title: 'Enhancement' }
}
}
expect(matchesSearch(worktree, '#304', repoMap, prCache, null)).toBe(true)
})
it('strips # prefix for issue number matching', () => {
const w = { ...worktree, linkedIssue: 55 }
expect(matchesSearch(w, '#55', repoMap, null, null)).toBe(true)
})
it('returns false when nothing matches', () => {
expect(matchesSearch(worktree, 'nonexistent-query-xyz', repoMap, null, null)).toBe(false)
})
it('handles null PR cache entry data gracefully', () => {
const prCache = {
'/tmp/orca::feature/super-critical': { data: null }
}
// Should fall through to other checks without error
expect(matchesSearch(worktree, 'anything', repoMap, prCache, null)).toBe(false)
})
it('handles null issue cache entry data gracefully', () => {
const w = { ...worktree, linkedIssue: 99 }
const issueCache = {
'/tmp/orca::99': { data: null }
}
// Should match on issue number but not crash on null title
expect(matchesSearch(w, '99', repoMap, null, issueCache)).toBe(true)
})
it('does not treat bare # as a wildcard that matches all numbers', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
data: { number: 304, title: 'Enhancement' }
}
}
const w = { ...worktree, linkedIssue: 99 }
expect(matchesSearch(w, '#', repoMap, prCache, null)).toBe(false)
})
})
describe('buildRows with pinned worktrees', () => {
const pinned = { ...worktree, id: 'wt-pinned', isPinned: true, displayName: 'pinned-feature' }
const unpinned1 = { ...worktree, id: 'wt-1', displayName: 'alpha' }
@@ -251,73 +251,6 @@ export function buildRows(
return result
}
/**
* Returns true when the worktree matches the search query against any of:
* displayName, branch, repo name, comment, PR number/title, issue number/title.
* `q` must already be lowercased by the caller.
*/
export function matchesSearch(
w: Worktree,
q: string,
repoMap: Map<string, Repo>,
prCache: Record<string, { data?: { number: number; title: string } | null }> | null,
issueCache: Record<string, { data?: { number: number; title: string } | null }> | null
): boolean {
// Cheap field checks first
if (w.displayName.toLowerCase().includes(q)) {
return true
}
if (branchName(w.branch).toLowerCase().includes(q)) {
return true
}
if ((repoMap.get(w.repoId)?.displayName ?? '').toLowerCase().includes(q)) {
return true
}
if (w.comment && w.comment.toLowerCase().includes(q)) {
return true
}
// Strip leading '#' so that searching "#304" matches number 304.
// Guard against bare '#' which would produce an empty string and match everything.
const numQuery = q.startsWith('#') ? q.slice(1) : q
if (!numQuery) {
return false
}
// PR: check auto-detected PR from cache, then manual linkedPR as fallback
const repo = repoMap.get(w.repoId)
const branch = branchName(w.branch)
const prKey = repo && branch ? `${repo.path}::${branch}` : ''
const pr = prKey && prCache ? prCache[prKey]?.data : undefined
if (pr) {
if (String(pr.number).includes(numQuery)) {
return true
}
if (pr.title.toLowerCase().includes(q)) {
return true
}
} else if (w.linkedPR != null) {
if (String(w.linkedPR).includes(numQuery)) {
return true
}
}
// Issue: check linkedIssue number and cached title
if (w.linkedIssue != null) {
if (String(w.linkedIssue).includes(numQuery)) {
return true
}
const issueKey = repo ? `${repo.path}::${w.linkedIssue}` : ''
const issue = issueKey && issueCache ? issueCache[issueKey]?.data : undefined
if (issue?.title.toLowerCase().includes(q)) {
return true
}
}
return false
}
export function getGroupKeyForWorktree(
groupBy: 'none' | 'repo' | 'pr-status',
worktree: Worktree,
@@ -0,0 +1,387 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, LoaderCircle, Pencil, Plus, RefreshCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft'
import type { SparsePreset } from '../../../../shared/types'
type SparseCheckoutPresetSelectProps = {
repoId: string
presets: SparsePreset[]
selectedPresetId: string | null
onSelectPreset: (preset: SparsePreset | null) => void
disabled?: boolean
}
type PresetDraft = {
mode: 'new' | 'edit'
presetId?: string
name: string
directoriesText: string
}
export default function SparseCheckoutPresetSelect({
repoId,
presets,
selectedPresetId,
onSelectPreset,
disabled = false
}: SparseCheckoutPresetSelectProps): React.JSX.Element {
const fetchSparsePresets = useAppStore((s) => s.fetchSparsePresets)
const saveSparsePreset = useAppStore((s) => s.saveSparsePreset)
const presetsForRepo = useAppStore((s) => s.sparsePresetsByRepo[repoId])
const presetsLoadStatus = useAppStore((s) => s.sparsePresetsLoadStatusByRepo[repoId] ?? 'idle')
const presetsLoading = presetsLoadStatus === 'loading'
const presetsLoadError = useAppStore((s) => s.sparsePresetsErrorByRepo[repoId] ?? null)
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState<PresetDraft | null>(null)
const [submitting, setSubmitting] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
const visiblePresets = presetsForRepo ?? presets
const presetsLoaded = presetsForRepo !== undefined
const isLoadingPresets = !disabled && presetsLoading
const hasPresetLoadError = !disabled && !presetsLoaded && !!presetsLoadError
const selectedPreset = useMemo(
() => visiblePresets.find((preset) => preset.id === selectedPresetId) ?? null,
[visiblePresets, selectedPresetId]
)
const parsedDirectories = draft ? parseSparsePresetDirectories(draft.directoriesText) : null
const trimmedName = draft?.name.trim() ?? ''
const nameCollision =
draft && trimmedName
? (visiblePresets.find(
(preset) =>
preset.id !== draft.presetId && preset.name.toLowerCase() === trimmedName.toLowerCase()
) ?? null)
: null
const nameError =
draft && trimmedName.length === 0
? 'Name is required.'
: trimmedName.length > 80
? 'Name must be 80 characters or fewer.'
: nameCollision
? `"${nameCollision.name}" already exists.`
: null
const canSave =
draft !== null &&
!submitting &&
!disabled &&
presetsLoaded &&
!nameError &&
parsedDirectories !== null &&
!parsedDirectories.error
const startDraft = useCallback(
(nextDraft: PresetDraft): void => {
if (disabled || !presetsLoaded) {
return
}
setDraft(nextDraft)
requestAnimationFrame(() => {
nameInputRef.current?.focus()
nameInputRef.current?.select()
})
},
[disabled, presetsLoaded]
)
const startNewPreset = useCallback((): void => {
startDraft({ mode: 'new', name: '', directoriesText: '' })
}, [startDraft])
const handleRetryLoadPresets = useCallback((): void => {
if (disabled || presetsLoading) {
return
}
setDraft(null)
void fetchSparsePresets(repoId)
}, [disabled, fetchSparsePresets, presetsLoading, repoId])
const startEditPreset = useCallback(
(preset: SparsePreset): void => {
startDraft({
mode: 'edit',
presetId: preset.id,
name: preset.name,
directoriesText: preset.directories.join('\n')
})
},
[startDraft]
)
const handleSaveDraft = useCallback(async (): Promise<void> => {
if (!draft || !canSave || !parsedDirectories) {
return
}
setSubmitting(true)
try {
const saved = await saveSparsePreset({
repoId,
id: draft.presetId,
name: trimmedName,
directories: parsedDirectories.directories
})
if (saved) {
if (draft.mode === 'new' || selectedPresetId === saved.id) {
onSelectPreset(saved)
}
setDraft(null)
setOpen(false)
}
} finally {
setSubmitting(false)
}
}, [
canSave,
draft,
onSelectPreset,
parsedDirectories,
repoId,
saveSparsePreset,
selectedPresetId,
trimmedName
])
const handleSelectOff = useCallback((): void => {
if (disabled || !presetsLoaded) {
return
}
onSelectPreset(null)
setDraft(null)
setOpen(false)
}, [disabled, onSelectPreset, presetsLoaded])
const handleSelectPreset = useCallback(
(preset: SparsePreset): void => {
if (disabled || !presetsLoaded) {
return
}
onSelectPreset(preset)
setDraft(null)
setOpen(false)
},
[disabled, onSelectPreset, presetsLoaded]
)
const triggerLabel = isLoadingPresets
? 'Loading presets...'
: hasPresetLoadError
? 'Retry loading presets'
: !presetsLoaded
? 'Load presets'
: selectedPreset
? selectedPreset.name
: 'Off'
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen && presetsLoading) {
setOpen(false)
setDraft(null)
return
}
setOpen(nextOpen)
if (!nextOpen) {
setDraft(null)
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-busy={isLoadingPresets}
disabled={disabled || isLoadingPresets}
className="h-9 w-full justify-between px-3 text-sm font-normal text-foreground"
>
<span className="truncate">{triggerLabel}</span>
{isLoadingPresets ? (
<LoaderCircle className="size-3.5 animate-spin opacity-60" />
) : hasPresetLoadError || !presetsLoaded ? (
<RefreshCcw className="size-3.5 opacity-60" />
) : (
<ChevronsUpDown className="size-3.5 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{draft ? (
<form
onSubmit={(event) => {
event.preventDefault()
void handleSaveDraft()
}}
>
<div className="border-b border-border px-3 py-2 text-xs font-medium text-foreground">
{draft.mode === 'new' ? 'New preset' : 'Edit preset'}
</div>
<div className="space-y-3 px-3 py-3">
<div className="space-y-1">
<label
htmlFor="sparse-preset-name"
className="block text-[11px] font-medium text-muted-foreground"
>
Name
</label>
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
<input
id="sparse-preset-name"
ref={nameInputRef}
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="Renderer UI"
maxLength={80}
autoComplete="off"
spellCheck={false}
className="h-8 w-full bg-transparent text-xs text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="space-y-1">
<label
htmlFor="sparse-preset-directories"
className="block text-[11px] font-medium text-muted-foreground"
>
Directories
</label>
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
<textarea
id="sparse-preset-directories"
value={draft.directoriesText}
onChange={(event) =>
setDraft({ ...draft, directoriesText: event.target.value })
}
placeholder={`src/renderer\npackages/ui`}
rows={3}
spellCheck={false}
className="max-h-28 w-full min-w-0 resize-none bg-transparent font-mono text-xs leading-5 text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
</div>
<div className="flex min-h-11 items-center justify-between gap-3 border-t border-border px-3 py-2">
<div className="min-w-0 text-[10px] text-muted-foreground">
{nameError ? (
<span className="text-destructive">{nameError}</span>
) : parsedDirectories?.error ? (
<span className="text-destructive">{parsedDirectories.error}</span>
) : parsedDirectories?.directories.length === 1 ? (
'1 directory'
) : (
`${parsedDirectories?.directories.length ?? 0} directories`
)}
</div>
<div className="flex shrink-0 justify-end gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDraft(null)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" size="sm" className="h-7 px-2 text-xs" disabled={!canSave}>
{submitting ? <LoaderCircle className="size-3 animate-spin" /> : null}
Save
</Button>
</div>
</div>
</form>
) : !presetsLoaded ? (
<div className="p-1">
{hasPresetLoadError ? (
<div className="px-2 py-1.5 text-[11px] text-destructive">
<span className="break-words">{presetsLoadError}</span>
</div>
) : null}
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
onClick={handleRetryLoadPresets}
>
<RefreshCcw className="size-3.5 text-muted-foreground" />
<span className="truncate">
{hasPresetLoadError ? 'Retry loading presets' : 'Load presets'}
</span>
</button>
</div>
) : (
<div>
<div className="py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-0.5rem)] items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
onClick={handleSelectOff}
>
<Check className={cn('size-4', selectedPreset ? 'opacity-0' : 'opacity-100')} />
Off
</button>
</div>
{visiblePresets.length > 0 ? (
<>
<div className="h-px bg-border" />
<div className="space-y-0.5 py-1">
{visiblePresets.map((preset) => (
<div
key={preset.id}
className="mx-1 flex items-center rounded-md hover:bg-accent hover:text-accent-foreground"
>
<button
type="button"
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-l-md px-2 text-left text-xs"
onClick={() => handleSelectPreset(preset)}
>
<Check
className={cn(
'size-4 shrink-0',
selectedPreset?.id === preset.id ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="truncate">{preset.name}</span>
</button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Edit ${preset.name}`}
className="mr-1 size-7 shrink-0 rounded-md text-muted-foreground hover:bg-background/35 hover:text-foreground"
onClick={() => startEditPreset(preset)}
>
<Pencil className="size-3.5" />
</Button>
</div>
))}
</div>
</>
) : null}
<div className="border-t border-border">
<Button
type="button"
variant="ghost"
onClick={startNewPreset}
className="mx-1 my-1 h-8 w-[calc(100%-0.5rem)] justify-start rounded-md px-2 text-xs font-normal"
>
<Plus className="size-3.5 text-muted-foreground" />
New preset
</Button>
</div>
</div>
)}
</PopoverContent>
</Popover>
)
}
@@ -29,6 +29,7 @@ export default function TabGroupPanel({
isFocused,
hasSplitGroups,
touchesRightEdge,
touchesLeftEdge,
reserveClosedExplorerToggleSpace,
reserveCollapsedSidebarHeaderSpace,
isTabDragActive = false,
@@ -41,6 +42,7 @@ export default function TabGroupPanel({
isFocused: boolean
hasSplitGroups: boolean
touchesRightEdge: boolean
touchesLeftEdge: boolean
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
isTabDragActive?: boolean
@@ -197,14 +199,14 @@ export default function TabGroupPanel({
// because a lone group has nothing to contrast against.
className={`group/tab-group flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups
? // Why: drop only the RIGHT border on the rightmost group. The right
// sidebar paints its own `borderLeft` that already extends full
// height, so painting our own border-r in that spot stacks a
// second 1px line next to it — reading as a 2px-thick bar below
// the 8px drag strip (where the sidebar border continues alone
// above). The left edge has no such double-up, so the left
// border is always kept.
` border-l ${touchesRightEdge ? '' : 'border-r'} border-border border-b ${isFocused ? 'border-b-accent' : 'opacity-95'}`
? // Why: drop the outer borders on the edge-touching groups. The
// TabGroupSplitLayout wrapper already paints a full-height
// `border-l` at the sidebar seam, and the right sidebar paints
// its own `borderLeft` at the right seam — painting our own
// border-l/border-r in those spots stacks a second 1px line
// next to it, reading as a ~2px bar below the drag strip
// (where the sibling border continues alone above).
` ${touchesLeftEdge ? '' : 'border-l'} ${touchesRightEdge ? '' : 'border-r'} border-border border-b ${isFocused ? 'border-b-accent' : 'opacity-95'}`
: ''
}`}
onPointerDown={commands.focusGroup}
@@ -223,7 +225,7 @@ export default function TabGroupPanel({
this, the empty space after tabs in the center column is dead — the
user can only drag from the tiny left-sidebar header strip. */}
<div
className="h-[34px] shrink-0 border-b border-border bg-card"
className="h-[32px] shrink-0 border-b border-border bg-card"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div className="flex h-full items-stretch pr-1.5">
@@ -126,6 +126,7 @@ function SplitNode({
isFocused={isWorktreeActive && node.groupId === focusedGroupId}
hasSplitGroups={hasSplitGroups}
touchesRightEdge={touchesRightEdge}
touchesLeftEdge={touchesLeftEdge}
reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge}
reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge}
isTabDragActive={isTabDragActive}
@@ -217,19 +218,23 @@ export default function TabGroupSplitLayout({
// so disabling it is the simplest fix.
autoScroll={false}
>
{/* Why: the 6px drag strip sits ABOVE the split layout — lifted out of
{/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of
each pane — so vertical split resize handles don't extend into the
window-drag region at the top. Only the split layout's own panes
own the resize handles, while this strip keeps the whole top of the
center column draggable regardless of how the splits are arranged.
Why `border-l` on the wrapper: paint a single full-height divider
between the left sidebar and the terminal area, regardless of
split state. When splits exist, the leftmost pane also renders its
own `border-l` at the same x — both use `border-border` so the
overlapping 1px lines read as one clean seam. */}
Why 10px specifically: pairs with the 32px tab row below so the
total top-band is 42px, matching the sibling `titlebar-left` above
the sidebar. Without this, the tab row's bottom border falls short
of the sidebar header's and the seam between columns reads as off.
Why `border-l` on the wrapper: paint the single full-height divider
between the left sidebar and the terminal area, regardless of split
state. The leftmost pane suppresses its own `border-l` via
`touchesLeftEdge`, so the seam is always exactly 1px — previously
both painted and stacked into a 2px bar below the drag strip. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border">
<div
className="h-[6px] shrink-0 bg-card"
className="h-[10px] shrink-0 bg-card"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
+125 -9
View File
@@ -16,6 +16,7 @@ import type {
OrcaHooks,
SetupDecision,
SetupRunPolicy,
SparsePreset,
TuiAgent
} from '../../../shared/types'
import {
@@ -35,6 +36,7 @@ import {
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
export type UseComposerStateOptions = {
initialRepoId?: string
@@ -119,6 +121,13 @@ export type ComposerCardProps = {
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: string | null
canUseSparseCheckout: boolean
/** Saved presets for the currently-selected repo. Empty array when no
* presets exist or when the repo is remote. */
sparsePresets: SparsePreset[]
/** ID of the selected sparse preset. Null means sparse checkout is off. */
sparseSelectedPresetId: string | null
onSparseSelectPreset: (preset: SparsePreset | null) => void
}
export type UseComposerStateResult = {
@@ -142,6 +151,7 @@ export type UseComposerStateResult = {
// modal wins when both are present, and the page takes over once the modal
// closes.
const composerDropStack: symbol[] = []
const EMPTY_SPARSE_PRESETS: SparsePreset[] = []
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
const {
@@ -172,7 +182,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
closeModal: s.closeModal,
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget,
prefetchWorkItems: s.prefetchWorkItems
prefetchWorkItems: s.prefetchWorkItems,
fetchSparsePresets: s.fetchSparsePresets
}))
)
const {
@@ -186,7 +197,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
closeModal,
openSettingsPage,
openSettingsTarget,
prefetchWorkItems
prefetchWorkItems,
fetchSparsePresets
} = actions
const repos = useAppStore((s) => s.repos)
@@ -194,7 +206,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const settings = useAppStore((s) => s.settings)
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
@@ -298,6 +310,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [advancedOpen, setAdvancedOpen] = useState(
persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false
)
const [sparseEnabled, setSparseEnabled] = useState(false)
const [sparseDirectories, setSparseDirectories] = useState('')
const [sparseSelectedPresetId, setSparseSelectedPresetId] = useState<string | null>(null)
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
const [linkQuery, setLinkQuery] = useState('')
@@ -331,6 +346,46 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agentPromptRef.current = agentPrompt
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
const normalizedSparseDirectories = useMemo(
() => normalizeSparseDirectoryLines(sparseDirectories),
[sparseDirectories]
)
// Why: a preset attribution should only ride along if what's about to be
// created actually equals the saved preset. If the user picked a preset and
// then edited the textarea, we want the worktree to be a "Custom" sparse
// checkout — not falsely tagged as the original preset.
const effectivePresetId = useMemo(() => {
if (!sparseSelectedPresetId) {
return null
}
const selected = sparsePresets.find((preset) => preset.id === sparseSelectedPresetId)
if (!selected) {
return null
}
return sparseDirectoriesMatch(selected.directories, normalizedSparseDirectories)
? selected.id
: null
}, [normalizedSparseDirectories, sparsePresets, sparseSelectedPresetId])
const sparseError = useMemo(() => {
if (!sparseEnabled) {
return null
}
if (selectedRepo?.connectionId) {
return 'Sparse checkout is only supported for local repos right now.'
}
if (normalizedSparseDirectories.length === 0) {
return 'Enter at least one repo-relative directory.'
}
if (
normalizedSparseDirectories.some((entry) => entry === '.' || entry.split('/').includes('..'))
) {
return 'Use repo-relative directories, not root or parent paths.'
}
return null
}, [normalizedSparseDirectories, selectedRepo?.connectionId, sparseEnabled])
const parsedLinkedIssueNumber = useMemo(
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
[linkedIssue]
@@ -483,6 +538,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [eligibleRepos, repoId, setRepoId])
// Why: the compact sparse dropdown is always visible under Advanced, so
// presets must load before sparse mode is enabled.
useEffect(() => {
if (!repoId || selectedRepo?.connectionId) {
return
}
if (sparsePresetsByRepo[repoId] !== undefined) {
return
}
void fetchSparsePresets(repoId)
}, [fetchSparsePresets, repoId, selectedRepo?.connectionId, sparsePresetsByRepo])
// Why: detect agents for the selected repo. For local repos this runs once
// on mount (deduped by the store). For remote repos it re-runs when the
// selected repo changes so the agent list matches the SSH host.
@@ -920,6 +987,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem(null)
setSparseEnabled(false)
setSparseDirectories('')
// Why: presets are repo-scoped, so a stale selection from the prior
// repo would be meaningless after a repo switch.
setSparseSelectedPresetId(null)
// Why: the Start-from picker is repo-scoped, so any prior branch/PR
// selection is meaningless in the new repo. Resetting to undefined
// makes the field fall back to the new repo's effective base ref.
@@ -929,6 +1001,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[baseBranch, linkedWorkItem, repoId, setRepoId]
)
const handleSparseSelectPreset = useCallback((preset: SparsePreset | null): void => {
if (preset) {
setSparseEnabled(true)
setSparseDirectories(preset.directories.join('\n'))
setSparseSelectedPresetId(preset.id)
} else {
setSparseEnabled(false)
setSparseDirectories('')
setSparseSelectedPresetId(null)
}
}, [])
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
setBaseBranch(next)
setStartFromResetHint(null)
@@ -993,7 +1077,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!selectedRepo ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
(requiresExplicitSetupChoice && !setupDecision)
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
return
}
@@ -1015,7 +1100,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
const result = await createWorktree(repoId, workspaceName, baseBranch, effectiveSetupDecision)
const result = await createWorktree(
repoId,
workspaceName,
baseBranch,
effectiveSetupDecision,
sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined
)
const worktree = result.worktree
await applyWorktreeMeta(worktree.id, {
@@ -1075,6 +1171,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
issueCommandTemplate,
linkedPR,
linkedWorkItem?.url,
normalizedSparseDirectories,
note,
onCreated,
parsedLinkedIssueNumber,
@@ -1089,6 +1186,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setRightSidebarTab,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,
effectivePresetId,
tuiAgent,
shouldRunIssueAutomation,
shouldWaitForIssueAutomationCheck,
@@ -1111,7 +1211,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!workspaceName ||
!selectedRepo ||
shouldWaitForSetupCheck ||
(requiresExplicitSetupChoice && !setupDecision)
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
return
}
@@ -1129,7 +1230,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
repoId,
workspaceName,
baseBranch,
effectiveSetupDecision
effectiveSetupDecision,
sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined
)
const worktree = result.worktree
@@ -1181,6 +1288,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
createWorktree,
fallbackCreatureName,
name,
normalizedSparseDirectories,
note,
onCreated,
persistDraft,
@@ -1194,6 +1302,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setRightSidebarTab,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,
effectivePresetId,
shouldWaitForSetupCheck
]
)
@@ -1204,7 +1315,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
creating ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
(requiresExplicitSetupChoice && !setupDecision)
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
const cardProps: ComposerCardProps = {
eligibleRepos,
@@ -1258,7 +1370,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onSetupDecisionChange: setSetupDecision,
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError
createError,
canUseSparseCheckout: !selectedRepo?.connectionId,
sparsePresets,
sparseSelectedPresetId,
onSparseSelectPreset: handleSparseSelectPreset
}
return {
+41
View File
@@ -0,0 +1,41 @@
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
export function isAbsoluteSparseDirectoryPath(value: string): boolean {
const entry = value.trim()
return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
}
/** Normalize the user's free-form textarea input into a clean directory list:
* trim whitespace, convert backslashes to forward slashes, strip leading and
* trailing slashes, drop empty lines, dedupe. Order preserved so the
* textarea round-trips with the user's typing intent intact. */
export function normalizeSparseDirectoryLines(value: string): string[] {
const seen = new Set<string>()
return value
.split('\n')
.map((entry) =>
entry
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
)
.filter((entry) => entry.length > 0)
.filter((entry) => {
if (seen.has(entry)) {
return false
}
seen.add(entry)
return true
})
}
/** Order-independent set comparison so "shared/ui\npackages/web" matches
* "packages/web\nshared/ui" — used by the composer to decide whether the
* current textarea content matches a saved preset. */
export function sparseDirectoriesMatch(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
const set = new Set(left)
return right.every((entry) => set.has(entry))
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { parseSparsePresetDirectories } from './sparse-preset-draft'
describe('parseSparsePresetDirectories', () => {
it('normalizes textarea input into unique repo-relative directories', () => {
expect(
parseSparsePresetDirectories(`
src\\renderer
packages/ui/
src/renderer
`)
).toEqual({
directories: ['src/renderer', 'packages/ui'],
error: null
})
})
it('requires at least one directory', () => {
expect(parseSparsePresetDirectories(' \n ')).toEqual({
directories: [],
error: 'Add at least one directory.'
})
})
it('rejects root and parent path entries', () => {
expect(parseSparsePresetDirectories('.')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
expect(parseSparsePresetDirectories('src/../packages')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
expect(parseSparsePresetDirectories('/')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
})
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
'rejects absolute directory input before normalization: %s',
(entry) => {
expect(parseSparsePresetDirectories(entry)).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
}
)
})
@@ -0,0 +1,42 @@
import { isAbsoluteSparseDirectoryPath, normalizeSparseDirectoryLines } from '@/lib/sparse-paths'
export type SparsePresetDirectoryParseResult = {
directories: string[]
error: string | null
}
export function parseSparsePresetDirectories(value: string): SparsePresetDirectoryParseResult {
const rawEntries = value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
// Why: absolute paths can look repo-relative after slash normalization.
if (rawEntries.some(isAbsoluteSparseDirectoryPath)) {
return {
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
}
}
const directories = normalizeSparseDirectoryLines(value)
if (directories.length === 0) {
return {
directories,
error: 'Add at least one directory.'
}
}
if (directories.some((entry) => entry === '.' || entry.split('/').includes('..'))) {
return {
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
}
}
return {
directories,
error: null
}
}
@@ -112,9 +112,6 @@ export function activateAndRevealWorktree(
// Why: revealWorktreeInSidebar relies on the worktree card being rendered
// in the sidebar. If sidebar filters exclude the target, the card is never
// rendered and the reveal silently no-ops.
if (state.searchQuery) {
state.setSearchQuery('')
}
if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(wt.repoId)) {
state.setFilterRepoIds([])
}
+2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { AppState } from './types'
import { createRepoSlice } from './slices/repos'
import { createSparsePresetsSlice } from './slices/sparse-presets'
import { createWorktreeSlice } from './slices/worktrees'
import { createTerminalSlice } from './slices/terminals'
import { createTabsSlice } from './slices/tabs'
@@ -25,6 +26,7 @@ import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
export const useAppStore = create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
-1
View File
@@ -115,7 +115,6 @@ export const useSidebarWidth = () => useAppStore((s) => s.sidebarWidth)
export const useActiveView = () => useAppStore((s) => s.activeView)
export const useActiveModal = () => useAppStore((s) => s.activeModal)
export const useModalData = () => useAppStore((s) => s.modalData)
export const useSearchQuery = () => useAppStore((s) => s.searchQuery)
export const useGroupBy = () => useAppStore((s) => s.groupBy)
export const useSortBy = () => useAppStore((s) => s.sortBy)
export const useShowActiveOnly = () => useAppStore((s) => s.showActiveOnly)
@@ -0,0 +1,233 @@
import { create } from 'zustand'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SparsePreset } from '../../../../shared/types'
import type { AppState } from '../types'
import { createSparsePresetsSlice } from './sparse-presets'
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn()
}
}))
const mockApi = {
sparsePresets: {
list: vi.fn(),
save: vi.fn(),
remove: vi.fn()
}
}
// @ts-expect-error -- test shim
globalThis.window = { api: mockApi }
function createTestStore() {
return create<AppState>()((...a) => ({ ...createSparsePresetsSlice(...a) }) as AppState)
}
function makePreset(
overrides: Partial<SparsePreset> & { id: string; repoId: string }
): SparsePreset {
return {
name: overrides.id,
directories: ['packages/app'],
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('createSparsePresetsSlice', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApi.sparsePresets.list.mockResolvedValue([])
mockApi.sparsePresets.save.mockImplementation((args: Partial<SparsePreset>) =>
Promise.resolve(
makePreset({
id: args.id ?? `preset-${args.name}`,
repoId: args.repoId ?? 'repo-1',
name: args.name ?? 'Preset',
directories: args.directories ?? ['packages/app'],
updatedAt: 2
})
)
)
mockApi.sparsePresets.remove.mockResolvedValue(undefined)
})
it('fetches presets into the requested repo bucket', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
mockApi.sparsePresets.list.mockResolvedValueOnce([preset])
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
expect(store.getState().sparsePresetsByRepo).toEqual({ 'repo-1': [preset] })
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
})
it('keeps an unfetched repo bucket missing while presets are loading', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
let resolveList: (presets: SparsePreset[]) => void = () => {}
mockApi.sparsePresets.list.mockReturnValueOnce(
new Promise<SparsePreset[]>((resolve) => {
resolveList = resolve
})
)
const fetchPromise = store.getState().fetchSparsePresets('repo-1')
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(true)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loading')
resolveList([preset])
await fetchPromise
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
})
it('does not refetch while a repo bucket is loading or already loaded', async () => {
const store = createTestStore()
let resolveList: (presets: SparsePreset[]) => void = () => {}
mockApi.sparsePresets.list.mockReturnValueOnce(
new Promise<SparsePreset[]>((resolve) => {
resolveList = resolve
})
)
const fetchPromise = store.getState().fetchSparsePresets('repo-1')
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
resolveList([])
await fetchPromise
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
})
it('clears loading state without marking the repo loaded when fetch fails', async () => {
const store = createTestStore()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
try {
await store.getState().fetchSparsePresets('repo-1')
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBe('disk failed')
} finally {
consoleError.mockRestore()
}
})
it('clears a failed fetch status when retrying presets succeeds', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockApi.sparsePresets.list
.mockRejectedValueOnce(new Error('disk failed'))
.mockResolvedValueOnce([preset])
try {
await store.getState().fetchSparsePresets('repo-1')
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(2)
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
} finally {
consoleError.mockRestore()
}
})
it('saves presets per repo and sorts the repo list by name', async () => {
const store = createTestStore()
store.setState({
sparsePresetsByRepo: {
'repo-1': [makePreset({ id: 'z', repoId: 'repo-1', name: 'Zed' })],
'repo-2': [makePreset({ id: 'other', repoId: 'repo-2', name: 'Other' })]
}
} as Partial<AppState>)
const saved = await store.getState().saveSparsePreset({
repoId: 'repo-1',
name: 'Api',
directories: ['packages/api']
})
expect(saved?.name).toBe('Api')
expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
'Api',
'Zed'
])
expect(store.getState().sparsePresetsByRepo['repo-2'].map((preset) => preset.name)).toEqual([
'Other'
])
})
it('loads an unfetched repo bucket before saving so existing presets stay visible', async () => {
const store = createTestStore()
const existing = makePreset({ id: 'existing', repoId: 'repo-1', name: 'Existing' })
mockApi.sparsePresets.list.mockResolvedValueOnce([existing])
const saved = await store.getState().saveSparsePreset({
repoId: 'repo-1',
name: 'Api',
directories: ['packages/api']
})
expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
expect(mockApi.sparsePresets.save).toHaveBeenCalledTimes(1)
expect(saved?.name).toBe('Api')
expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
'Api',
'Existing'
])
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
})
it('does not save or synthesize a repo bucket when presets fail to load first', async () => {
const store = createTestStore()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
try {
const saved = await store.getState().saveSparsePreset({
repoId: 'repo-1',
name: 'Api',
directories: ['packages/api']
})
expect(saved).toBeNull()
expect(mockApi.sparsePresets.save).not.toHaveBeenCalled()
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
} finally {
consoleError.mockRestore()
}
})
it('restores the previous repo presets when remove fails', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
mockApi.sparsePresets.remove.mockRejectedValueOnce(new Error('disk failed'))
store.setState({ sparsePresetsByRepo: { 'repo-1': [preset] } } as Partial<AppState>)
await store.getState().removeSparsePreset({ repoId: 'repo-1', presetId: 'preset-1' })
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
})
})
@@ -0,0 +1,135 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { SparsePreset } from '../../../../shared/types'
const ERROR_TOAST_DURATION = 60_000
export type SparsePresetsLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'
export type SparsePresetsSlice = {
/** Per-repo preset list. Lazily populated by `fetchSparsePresets`; missing
* key means "not yet fetched", empty array means "fetched, none exist". */
sparsePresetsByRepo: Record<string, SparsePreset[]>
/** Per-repo fetch guard so missing preset buckets keep their loading meaning. */
sparsePresetsLoadingByRepo: Record<string, boolean>
sparsePresetsLoadStatusByRepo: Record<string, SparsePresetsLoadStatus>
sparsePresetsErrorByRepo: Record<string, string | undefined>
fetchSparsePresets: (repoId: string) => Promise<void>
saveSparsePreset: (args: {
repoId: string
id?: string
name: string
directories: string[]
}) => Promise<SparsePreset | null>
removeSparsePreset: (args: { repoId: string; presetId: string }) => Promise<void>
}
export const createSparsePresetsSlice: StateCreator<AppState, [], [], SparsePresetsSlice> = (
set,
get
) => ({
sparsePresetsByRepo: {},
sparsePresetsLoadingByRepo: {},
sparsePresetsLoadStatusByRepo: {},
sparsePresetsErrorByRepo: {},
fetchSparsePresets: async (repoId) => {
const state = get()
if (
state.sparsePresetsByRepo[repoId] !== undefined ||
state.sparsePresetsLoadingByRepo[repoId]
) {
return
}
set((s) => ({
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: true },
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loading' },
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
}))
try {
const presets = await window.api.sparsePresets.list({ repoId })
set((s) => ({
sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: presets },
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loaded' },
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
}))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set((s) => ({
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'error' },
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: message }
}))
console.error(`Failed to fetch sparse presets for repo ${repoId}:`, err)
}
},
saveSparsePreset: async (args) => {
try {
if (get().sparsePresetsByRepo[args.repoId] === undefined) {
// Why: a saved preset alone is not an authoritative repo bucket; load
// existing presets first so we do not hide them behind a one-item cache.
await get().fetchSparsePresets(args.repoId)
if (get().sparsePresetsByRepo[args.repoId] === undefined) {
toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
description: 'Presets must load before saving.',
duration: ERROR_TOAST_DURATION
})
return null
}
}
const saved = await window.api.sparsePresets.save(args)
set((s) => {
const existing = s.sparsePresetsByRepo[args.repoId]
if (existing === undefined) {
return {}
}
const without = existing.filter((preset) => preset.id !== saved.id)
return {
sparsePresetsByRepo: {
...s.sparsePresetsByRepo,
[args.repoId]: [...without, saved].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
}
})
toast.success(args.id ? 'Preset updated' : 'Preset saved', { description: saved.name })
return saved
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
description: message,
duration: ERROR_TOAST_DURATION
})
return null
}
},
removeSparsePreset: async ({ repoId, presetId }) => {
const previous = get().sparsePresetsByRepo[repoId] ?? []
// Why: optimistic local update keeps the popover responsive — toast handles
// the failure path by restoring state.
set((s) => ({
sparsePresetsByRepo: {
...s.sparsePresetsByRepo,
[repoId]: previous.filter((preset) => preset.id !== presetId)
}
}))
try {
await window.api.sparsePresets.remove({ repoId, presetId })
toast.success('Preset removed')
} catch (err) {
set((s) => ({
sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: previous }
}))
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to remove preset', {
description: message,
duration: ERROR_TOAST_DURATION
})
}
}
})
@@ -90,6 +90,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@@ -113,6 +114,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history'
function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
@@ -9,6 +9,7 @@ import type {
} from '../../../../shared/types'
import type { OpenFile } from './editor'
import { createRepoSlice } from './repos'
import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@@ -40,6 +41,7 @@ export const TEST_REPO = {
export function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
@@ -85,6 +85,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@@ -110,6 +111,7 @@ const WT = 'repo1::/tmp/feature'
function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
-5
View File
@@ -177,8 +177,6 @@ export type UISlice = {
) => void
markOrcaHookRepoAlwaysTrusted: (repoId: string) => void
clearOrcaHookTrustForRepo: (repoId: string) => void
searchQuery: string
setSearchQuery: (q: string) => void
groupBy: 'none' | 'repo' | 'pr-status'
setGroupBy: (g: UISlice['groupBy']) => void
sortBy: 'name' | 'smart' | 'recent' | 'repo'
@@ -435,9 +433,6 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
return { trustedOrcaHooks: next }
}),
searchQuery: '',
setSearchQuery: (q) => set({ searchQuery: q }),
groupBy: 'none',
// Why: group keys are mode-specific (e.g. repo id vs PR status), so
// collapsed state from one mode is meaningless in another. Clearing
@@ -1,5 +1,6 @@
import type {
CreateWorktreeResult,
CreateSparseCheckoutRequest,
SetupDecision,
Worktree,
WorktreeMeta
@@ -40,7 +41,8 @@ export type WorktreeSlice = {
repoId: string,
name: string,
baseBranch?: string,
setupDecision?: SetupDecision
setupDecision?: SetupDecision,
sparseCheckout?: CreateSparseCheckoutRequest
) => Promise<CreateWorktreeResult>
removeWorktree: (
worktreeId: string,
+17 -3
View File
@@ -11,6 +11,16 @@ import {
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
if (a === b) {
return true
}
if (!a || !b || a.length !== b.length) {
return !a?.length && !b?.length
}
return a.every((v, i) => v === b[i])
}
function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean {
if (!current || current.length !== next.length) {
return false
@@ -26,6 +36,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.branch === candidate.branch &&
worktree.isBare === candidate.isBare &&
worktree.isMainWorktree === candidate.isMainWorktree &&
worktree.isSparse === candidate.isSparse &&
worktree.displayName === candidate.displayName &&
worktree.comment === candidate.comment &&
worktree.linkedIssue === candidate.linkedIssue &&
@@ -34,7 +45,9 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.isUnread === candidate.isUnread &&
worktree.isPinned === candidate.isPinned &&
worktree.sortOrder === candidate.sortOrder &&
worktree.lastActivityAt === candidate.lastActivityAt
worktree.lastActivityAt === candidate.lastActivityAt &&
worktree.sparseBaseRef === candidate.sparseBaseRef &&
arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories)
)
})
}
@@ -86,7 +99,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
await Promise.all(repos.map((r) => get().fetchWorktrees(r.id)))
},
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit') => {
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit', sparseCheckout) => {
const retryableConflictPatterns = [
/already exists locally/i,
/already exists on a remote/i,
@@ -103,7 +116,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
repoId,
name: candidateName,
baseBranch,
setupDecision
setupDecision,
sparseCheckout
})
// Why: a file watcher (worktrees.onChanged) can fire between the
// backend creating the worktree and this callback running, causing
+2
View File
@@ -1,4 +1,5 @@
import type { RepoSlice } from './slices/repos'
import type { SparsePresetsSlice } from './slices/sparse-presets'
import type { WorktreeSlice } from './slices/worktrees'
import type { TerminalSlice } from './slices/terminals'
import type { TabsSlice } from './slices/tabs'
@@ -20,6 +21,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents'
import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
export type AppState = RepoSlice &
SparsePresetsSlice &
WorktreeSlice &
TerminalSlice &
TabsSlice &
+1
View File
@@ -214,6 +214,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
repos: [],
sparsePresetsByRepo: {},
worktreeMeta: {},
settings: getDefaultSettings(homedir),
ui: getDefaultUIState(),
+40
View File
@@ -48,6 +48,7 @@ export type GitWorktreeInfo = {
head: string
branch: string
isBare: boolean
isSparse?: boolean
/** True for the repo's main working tree (the first entry from `git worktree list`).
* Linked worktrees created via `git worktree add` have this set to false. */
isMainWorktree: boolean
@@ -67,6 +68,11 @@ export type Worktree = {
isPinned: boolean
sortOrder: number
lastActivityAt: number
sparseDirectories?: string[]
sparseBaseRef?: string
/** ID of the saved preset this worktree was created from, if any. Cleared
* when the worktree is no longer sparse on refresh. */
sparsePresetId?: string
diffComments?: DiffComment[]
} & GitWorktreeInfo
@@ -82,6 +88,9 @@ export type WorktreeMeta = {
isPinned: boolean
sortOrder: number
lastActivityAt: number
sparseDirectories?: string[]
sparseBaseRef?: string
sparsePresetId?: string
diffComments?: DiffComment[]
}
@@ -690,11 +699,32 @@ export type WorktreeSetupLaunch = {
envVars: Record<string, string>
}
export type CreateSparseCheckoutRequest = {
directories: string[]
/** Set when the directories came from a saved preset and the user did not
* modify them — recorded on WorktreeMeta so the worktree can show "from
* preset X" later. Cleared if the user edited the textarea. */
presetId?: string
}
/** A reusable per-repo sparse directory list. Saved by the user from the
* composer; surfaced again the next time they create a worktree in the same
* repo. The MVP scope (no preset) is `presetId === undefined`. */
export type SparsePreset = {
id: string
repoId: string
name: string
directories: string[]
createdAt: number
updatedAt: number
}
export type CreateWorktreeArgs = {
repoId: string
name: string
baseBranch?: string
setupDecision?: SetupDecision
sparseCheckout?: CreateSparseCheckoutRequest
}
export type CreateWorktreeResult = {
@@ -1229,6 +1259,9 @@ export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookR
export type PersistedState = {
schemaVersion: number
repos: Repo[]
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
* presets are managed from the new-workspace composer and repo settings. */
sparsePresetsByRepo: Record<string, SparsePreset[]>
worktreeMeta: Record<string, WorktreeMeta>
settings: GlobalSettings
ui: PersistedUIState
@@ -1247,6 +1280,13 @@ export type DirEntry = {
isSymlink: boolean
}
export type MarkdownDocument = {
filePath: string
relativePath: string
basename: string
name: string
}
// ─── Filesystem watcher ─────────────────────────────────────
export type FsChangeEvent = {
kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow'