diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bda260be9aa..87fd0ba92fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index 8c857ad2305..e5624925de9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -

- Orca -

- -

Orca

+

+ Orca Orca +

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*).

Claude Code   @@ -49,7 +47,7 @@ Orca supports any CLI agent (_not just this list_). Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   @@ -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. diff --git a/docs/README.es.md b/docs/README.es.md index 62b6d60c534..ed58550d249 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -1,13 +1,11 @@ -

- Orca -

- -

Orca

+

+ Orca Orca +

- Supported Platforms + Plataformas compatibles Discord - Follow on X + Seguir en X

@@ -16,7 +14,7 @@

El orquestador de IA para desarrolladores 100x.
- 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.
+ Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.

@@ -25,12 +23,12 @@

- Captura de Orca + Captura de Orca

## 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*).

Claude Code   @@ -49,7 +47,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_). Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   @@ -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.

- Orca Anotar diff de IA — comenta en diffs generados por IA y envía feedback al agente + Orca Anotar diff de IA — comenta en diffs generados por IA y envía feedback al agente

--- @@ -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.

- Cambio de cuentas de Codex en Orca — alterna entre múltiples cuentas de Codex + Cambio de cuentas de Codex en Orca — alterna entre múltiples cuentas de Codex

--- -## [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.

- Modo diseño de Orca — haz clic en cualquier elemento de UI y suéltalo en el chat + Modo diseño de Orca — haz clic en cualquier elemento de UI y suéltalo en el chat

--- @@ -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). diff --git a/docs/README.ja.md b/docs/README.ja.md index cb1c8aa6aeb..d92b0c2f3ed 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -1,14 +1,11 @@ -

- Orca -

- -

Orca

+

+ Orca Orca +

- GitHub stars - Supported Platforms - Discord - Follow on X + 対応プラットフォーム + Discord + X でフォロー

@@ -16,9 +13,9 @@

- 100x 開発者のための AI IDE。
- Claude Code、Codex、または OpenCode をリポジトリをまたいで並行して実行 — 各エージェントは独立したワークツリーで実行され、1 つの場所で集中管理されます。
- macOS、Windows、Linux で利用可能です。 + 100x ビルダーのための AI オーケストレーター。
+ Claude Code、Codex、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
+ macOS、Windows、Linux で利用できます。

@@ -26,12 +23,12 @@

- Orca Screenshot + Orca Screenshot

-## 対応する AI Agent +## 対応するエージェント -Orca は任意の CLI Agent に対応しています(_このリストに限定されません_)。 +Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。

Claude Code   @@ -50,7 +47,7 @@ Orca は任意の CLI Agent に対応しています(_このリストに限定 Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   @@ -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 の任意の行にフィードバックを付け、そのままエージェントに返して修正させましょう。レビューのループを素早く保てます — 行番号をコピーする必要も、コンテキストを切り替える必要もありません。

- Orca AI Diff コメント — AI が生成した Diff にコメントしてエージェントにフィードバックを送信 + Orca AI Diff 注釈 — AI が生成した Diff にコメントしてエージェントにフィードバックを送信

--- ## [新機能] Codex アカウントのホットスワップ -**複数の Codex アカウントをお持ちですか?ワンクリックで切り替え可能です。** +**複数の Codex アカウントをお持ちですか?ワンクリックで切り替えできます。** -最適な API クォータを得るために複数の Codex アカウントを使用している場合、Orca では再ログインや設定ファイルなしで、即座にアカウントをホットスワップできます。ワンクリックでアカウントを切り替え可能です。 +最適なトークン条件を得るために複数の Codex アカウントを使っている場合、Orca なら再ログインも設定ファイルの編集も不要で、すぐにアカウントをホットスワップできます。アカウントを選んで、そのまま開発を続けられます。

- Orca Codex アカウント切り替え — 複数のアカウント間でホットスワップ + Orca Codex アカウント切り替え — 複数の Codex アカウント間でホットスワップ

--- -## [新機能] ワークツリーごとのブラウザ & デザインモード +## [新機能] ワークツリーごとのブラウザとデザインモード -**アプリをプレビュー。要素をクリックして。そのままチャットにドロップ。** +**アプリを表示。任意の要素をクリック。チャットにそのまま投入。** + +Orca には、ワークツリー内で使える組み込みブラウザがあります。アプリを作りながらプレビューし、デザインモードに切り替えると、任意の UI 要素をクリックするだけで AI チャットにコンテキストとして直接入ります。スクリーンショットもセレクターのコピーも不要です。変更したい箇所を指して、エージェントに指示するだけです。 -Orca には、ワークツリー内にブラウザが組み込まれています。いつでもアプリをプレビューし、デザインモードに切り替えます — 任意の UI 要素をクリックすると、コンテキストとしてそのまま AI チャットに取り込まれます。

- Orca デザインモード — UI 要素をクリックしてチャットにドロップ + Orca デザインモード — 任意の UI 要素をクリックしてチャットに投入

--- @@ -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) ガイドをご覧ください。 diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 40f15188ee8..163b1bfb64e 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -1,14 +1,11 @@ -

- Orca -

- -

Orca

+

+ Orca Orca +

- GitHub stars - Supported Platforms - Discord - Follow on X + 支持的平台 + Discord + 在 X 上关注

@@ -16,8 +13,8 @@

- 专为 100x 开发者打造的 AI IDE。
- 跨代码库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己独立的工作区中运行,并在一个地方集中管理。
+ 面向 100x 构建者的 AI 编排器。
+ 跨仓库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
支持 macOS、Windows 和 Linux。

@@ -26,12 +23,12 @@

- Orca Screenshot + Orca Screenshot

-## 支持的 AI Agents +## 支持的智能体 -Orca 支持任何 CLI Agent (_不仅限于以下列表_)。 +Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。

Claude Code   @@ -50,7 +47,7 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。 Cursor   Droid   GitHub Copilot   - Kilocode   + Kilocode   Kimi   Kiro   Mistral Vibe   @@ -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 的任意一行添加你的反馈,然后发回给智能体进行修改。让评审循环保持紧凑 — 无需复制行号,也无需切换上下文。

- Orca AI Diff 标注 — 在 AI 生成的 Diff 上评论并将反馈发送给 Agent + Orca 标注 AI Diff — 在 AI 生成的 diff 上评论并将反馈发送给智能体

--- @@ -114,30 +112,31 @@ yay -S stably-orca-git **有多个 Codex 账号?一键切换。** -如果你使用多个 Codex 账号以获得最佳 API 额度,Orca 让你能在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。只需一键点击切换账号。 +如果你使用多个 Codex 账号来获得更合适的 token 配额,Orca 可让你在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。选中账号,然后继续构建。

- Orca Codex 账号切换器 — 在多个账号之间热切换 + Orca Codex 账号切换器 — 在多个 Codex 账号之间热切换

--- -## [全新] 每工作树内置浏览器与设计模式 +## [全新] 每个 worktree 的浏览器和设计模式 -**预览应用。点击任意元素。直接拖入聊天框。** +**查看你的应用。点击任意元素。直接放入聊天。** + +Orca 在你的 worktree 中内置了浏览器。边构建边预览应用,然后切换到设计模式 — 点击任意 UI 元素,它会作为上下文直接进入你的 AI 聊天。无需截图,无需复制选择器。只需指向想修改的地方,然后告诉智能体要做什么。 -Orca 在你的工作树中内置了浏览器。随时预览你的应用,然后切换到设计模式 — 点击任何 UI 元素,它就会直接落入你的 AI 聊天框作为上下文。

- Orca 设计模式 — 点击任意 UI 元素并放入聊天框 + Orca 设计模式 — 点击任意 UI 元素并放入聊天

--- -## [全新] 隆重推出 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) 指南。 diff --git a/package.json b/package.json index 3e18f27696d..61d0c3f7eac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index eecf11344dc..97f8eee820b 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -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('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') + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4e62e94f07b..8678cbe749d 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise { +export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index c109d168d69..b77b89e33f6 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -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 { 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 { // 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 { + 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 { + // 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 `/.git/info/sparse-checkout`: + // linked worktrees have a `.git` file that points at + // `/.git/worktrees/`, 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 + } +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 39b348fa04b..4cc0e88d50c 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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: { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 50bbf5bcf10..2e22c981af5 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 => { + 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 ( diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts new file mode 100644 index 00000000000..cedf854be0b --- /dev/null +++ b/src/main/ipc/markdown-documents.ts @@ -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 { + const documents: MarkdownDocument[] = [] + + async function visitDirectory(dirPath: string): Promise { + 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)) +} diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts new file mode 100644 index 00000000000..71ab88b6a4d --- /dev/null +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -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() + 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 unknown> + +function makePreset( + overrides: Partial & { 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' + }) + }) +}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index b59aa8fb7ca..3f1b13df5d2 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 +} diff --git a/src/main/ipc/sparse-checkout-directories.ts b/src/main/ipc/sparse-checkout-directories.ts new file mode 100644 index 00000000000..74f2873d062 --- /dev/null +++ b/src/main/ipc/sparse-checkout-directories.ts @@ -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() + 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 + }) +} diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 70e60f2ef44..f4fc0e292b8 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -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. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 548dcc7cead..31867bbc6b6 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 { + 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) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 0bbba1a6a48..f32730d0b33 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0e9f89b8dc8..578c926c2e4 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 { diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts index 3ff4a252895..326a61b426d 100644 --- a/src/main/providers/process-cwd.ts +++ b/src/main/providers/process-cwd.ts @@ -70,7 +70,11 @@ async function doResolve(pid: number): Promise { } 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 }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4f96d74fd6f..8c59e0c384f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 onChanged: (callback: () => void) => () => void } + sparsePresets: { + list: (args: { repoId: string }) => Promise + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }) => Promise + remove: (args: { repoId: string; presetId: string }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void + } worktrees: { list: (args: { repoId: string }) => Promise listAll: () => Promise - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }) => Promise + create: (args: CreateWorktreeArgs) => Promise 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 writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise createFile: (args: { filePath: string; connectionId?: string }) => Promise createDir: (args: { dirPath: string; connectionId?: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ea52d9d292..cfd63c70fdf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('sparsePresets:list', args), + + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }): Promise => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args: { repoId: string; presetId: string }): Promise => + 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 => ipcRenderer.invoke('worktrees:list', args), listAll: (): Promise => ipcRenderer.invoke('worktrees:listAll'), - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }): Promise => ipcRenderer.invoke('worktrees:create', args), + create: (args: CreateWorktreeArgs): Promise => + 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 diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index ae9c2d2a042..72e26e1ec11 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -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('/')) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7837db669f8..aa6923d9b67 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { ) : 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. */} + + + + + + {`Search (${isMac ? '⌘J' : 'Ctrl+Shift+J'})`} + + {/* Why: Back/Forward traverse mixed worktree + Tasks history, so the cluster is shown wherever the history shortcut is live (terminal or diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 69c2b8c4820..fe5ec79226b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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 diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index fb37e366e75..625696c41f1 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -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; diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 59c0231bd14..25bcaacd260 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -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 { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 3b27aab1996..c72a55ebde8 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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({
@@ -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. */} -
+