mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
Merge remote-tracking branch 'origin/main' into add-a-pet-for-orca
# Conflicts: # src/preload/index.ts
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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('/')) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ↵ select 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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -214,6 +214,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
repos: [],
|
||||
sparsePresetsByRepo: {},
|
||||
worktreeMeta: {},
|
||||
settings: getDefaultSettings(homedir),
|
||||
ui: getDefaultUIState(),
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user