From 5bf114787ffc7b298bdf798b35deeb03d030212c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 1 May 2026 11:53:20 -0700 Subject: [PATCH 01/10] feat(sidebar): move worktree filter out of search bar into header (#1303) Co-authored-by: Orca --- .../src/components/sidebar/SearchBar.tsx | 164 ++---------------- .../src/components/sidebar/SidebarFilter.tsx | 140 +++++++++++++++ .../src/components/sidebar/SidebarHeader.tsx | 2 + 3 files changed, 154 insertions(+), 152 deletions(-) create mode 100644 src/renderer/src/components/sidebar/SidebarFilter.tsx diff --git a/src/renderer/src/components/sidebar/SearchBar.tsx b/src/renderer/src/components/sidebar/SearchBar.tsx index 172606bb987..b49ec748c32 100644 --- a/src/renderer/src/components/sidebar/SearchBar.tsx +++ b/src/renderer/src/components/sidebar/SearchBar.tsx @@ -1,76 +1,14 @@ import React, { useCallback } from 'react' -import { Search, X, Activity, ListFilter, FolderPlus } from 'lucide-react' +import { Search, X } 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 ( - - - Active - - ) - } - if (!showActiveOnly && hasRepoFilter && selectedRepos.length === 1) { - return ( - - ) - } - return {activeFilterCount} filters - })() return (
@@ -83,96 +21,18 @@ const SearchBar = React.memo(function SearchBar() { 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" + className="h-7 pl-7 pr-8 text-[12px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30 placeholder:text-muted-foreground/70" /> -
- {searchQuery && ( - - )} - - - - - - - - - {hasAnyFilter ? 'Edit filters' : 'Filter worktrees'} - - - - Status - event.preventDefault()} - > - - Active only - - {canFilterRepos && ( - <> - - Repositories - {repos.map((r) => ( - handleToggleRepo(r.id)} - onSelect={(event) => event.preventDefault()} - > - - - ))} - - )} - {hasAnyFilter && ( - <> - - { - setShowActiveOnly(false) - setFilterRepoIds([]) - }} - > - - Clear filters - - - )} - {canFilterRepos && ( - <> - - { - addRepo() - }} - > - - Add project - - - )} - - -
+ {searchQuery && ( + + )}
) diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx new file mode 100644 index 00000000000..6de1675a495 --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -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 ( + + + + + + + + + {hasAnyFilter ? 'Edit filters' : 'Filter workspaces'} + + + + Status + event.preventDefault()} + > + + Active only + + {canFilterRepos && ( + <> + + Repositories + {repos.map((r) => ( + handleToggleRepo(r.id)} + onSelect={(event) => event.preventDefault()} + > + + + ))} + + )} + {hasAnyFilter && ( + <> + + { + setShowActiveOnly(false) + setFilterRepoIds([]) + }} + > + + Clear filters + + + )} + {canFilterRepos && ( + <> + + { + addRepo() + }} + > + + Add project + + + )} + + + ) +}) + +export default SidebarFilter diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 3aceafaefe7..3afe12f8309 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -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' }, @@ -72,6 +73,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { Workspaces
+ From 48d7e20493ffb580d7de273e9b5a30204b7b446b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 1 May 2026 12:21:53 -0700 Subject: [PATCH 02/10] fix(release): dispatch Homebrew cask bump from release pipeline (#1315) Co-authored-by: Orca --- .github/workflows/release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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" From 22b80f8ce5fdaee307545d21fb5ad5a3be5e5782 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 1 May 2026 12:25:41 -0700 Subject: [PATCH 03/10] fix(terminal): split panes lose source cwd when shell omits OSC 7 (#1313) Co-authored-by: Orca --- src/main/providers/process-cwd.ts | 6 +++++- src/relay/pty-shell-utils.ts | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) 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/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('/')) { From 8d56823998be158e57a250a4a88928d312dcfadd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 1 May 2026 12:38:59 -0700 Subject: [PATCH 04/10] docs: localize README install updates (#1319) --- README.md | 64 ++++++++++++++++++---- docs/README.es.md | 88 +++++++++++++++++++++-------- docs/README.ja.md | 128 +++++++++++++++++++++++++++++-------------- docs/README.zh-CN.md | 126 ++++++++++++++++++++++++++++-------------- 4 files changed, 291 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 8c857ad2305..9e657004df3 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,97 @@ -

- Orca -

+

-

Orca

+ Orca Orca + +

+ Supported Platforms + Discord + Follow on X +

+ English · 中文 · 日本語 · Español +

+ The AI Orchestrator for 100x builders.
+ Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Available for macOS, Windows, and Linux. +

+ Download 🐋 +

+ Orca Screenshot +

## Supported Agents -Orca supports any CLI agent (_not just this list_). +Orca supports any CLI agent (*not just this list*).

+ Claude Code   + Codex   + Gemini   + Pi   + Hermes Agent   + OpenCode   + Goose   + Amp   + Auggie   + Charm   + Cline   + Codebuff   + Continue   + Cursor   + Droid   + GitHub Copilot   - Kilocode   + + Kilocode   + Kimi   + Kiro   + Mistral Vibe   + Qwen Code   + Rovo Dev +

--- @@ -73,9 +110,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 +133,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 @@ -104,7 +142,9 @@ See [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) and [s Annotate any line in an AI-generated diff with your feedback, then send it back to the agent to revise. Keep the review loop tight — no copying line numbers, no context switching.

+ Orca Annotate AI Diff — comment on AI-generated diffs and send feedback to the agent +

--- @@ -116,7 +156,9 @@ Annotate any line in an AI-generated diff with your feedback, then send it back If you run multiple Codex accounts to get the best token deal, Orca lets you hot-swap between them instantly — no re-login, no config files. Just pick an account and keep building.

+ Orca Codex Account Switcher — hot swap between multiple Codex accounts +

--- @@ -128,7 +170,9 @@ If you run multiple Codex accounts to get the best token deal, Orca lets you hot Orca ships with a built-in browser right inside your worktree. Preview your app as you build, then switch to Design Mode — click any UI element and it lands directly in your AI chat as context. No screenshots, no copy-pasting selectors. Just point at what you want to change and tell the agent what to do.

+ Orca Design Mode — click any UI element and drop it into the chat +

--- diff --git a/docs/README.es.md b/docs/README.es.md index 62b6d60c534..3423258995b 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -1,60 +1,97 @@ -

- Orca -

+

-

Orca

+ Orca Orca + +

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

+ English · 中文 · 日本語 · Español +

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

+ Descargar 🐋 +

- 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   + Codex   + Gemini   + Pi   + Hermes Agent   + OpenCode   + Goose   + Amp   + Auggie   + Charm   + Cline   + Codebuff   + Continue   + Cursor   + Droid   + GitHub Copilot   - Kilocode   + + Kilocode   + Kimi   + Kiro   + Mistral Vibe   + Qwen Code   + Rovo Dev +

--- @@ -63,19 +100,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 +133,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 +142,9 @@ 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 +156,23 @@ 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 +200,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](../CONTRIBUTING.md). diff --git a/docs/README.ja.md b/docs/README.ja.md index cb1c8aa6aeb..dad29c9f191 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -1,61 +1,97 @@ -

- Orca -

+

-

Orca

+ Orca Orca + +

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

+ English · 中文 · 日本語 · Español +

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

+ ダウンロード 🐋 +

- Orca Screenshot + + Orca Screenshot +

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

+ Claude Code   + Codex   + Gemini   + Pi   + Hermes Agent   + OpenCode   + Goose   + Amp   + Auggie   + Charm   + Cline   + Codebuff   + Continue   + Cursor   + Droid   + GitHub Copilot   - Kilocode   + + Kilocode   + Kimi   + Kiro   + Mistral Vibe   + Qwen Code   + Rovo Dev +

--- @@ -63,20 +99,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 +133,46 @@ 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 +181,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 +189,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](../CONTRIBUTING.md) ガイドをご覧ください。 diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 40f15188ee8..f70128969fb 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -1,82 +1,121 @@ -

- Orca -

+

-

Orca

+ Orca Orca + +

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

+ English · 中文 · 日本語 · Español +

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

+ 下载 🐋 +

- Orca Screenshot + + Orca Screenshot +

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

+ Claude Code   + Codex   + Gemini   + Pi   + Hermes Agent   + OpenCode   + Goose   + Amp   + Auggie   + Charm   + Cline   + Codebuff   + Continue   + Cursor   + Droid   + GitHub Copilot   - Kilocode   + + Kilocode   + Kimi   + Kiro   + Mistral Vibe   + Qwen Code   + Rovo Dev +

--- ## 特性 -- **免登录** — 直接用你的 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 +133,18 @@ 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 +153,35 @@ 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 +193,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](../CONTRIBUTING.md) 指南。 From 17110818c68db3da66d98d3689bbfe8fadadc55e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 1 May 2026 12:46:56 -0700 Subject: [PATCH 05/10] docs: fix README header alignment (#1320) --- README.md | 47 +------------------------------------------- docs/README.es.md | 47 +------------------------------------------- docs/README.ja.md | 47 +------------------------------------------- docs/README.zh-CN.md | 47 +------------------------------------------- 4 files changed, 4 insertions(+), 184 deletions(-) diff --git a/README.md b/README.md index 9e657004df3..e5624925de9 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,29 @@

- Orca Orca -

- Supported Platforms - Discord - Follow on X -

- English · 中文 · 日本語 · Español -

- The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
- Available for macOS, Windows, and Linux. -

- Download 🐋 -

- Orca Screenshot -

## Supported Agents @@ -47,51 +31,28 @@ Orca supports any CLI agent (*not just this list*).

- Claude Code   - Codex   - Gemini   - Pi   - Hermes Agent   - OpenCode   - Goose   - Amp   - Auggie   - Charm   - Cline   - Codebuff   - Continue   - Cursor   - Droid   - GitHub Copilot   - Kilocode   - Kimi   - Kiro   - Mistral Vibe   - Qwen Code   - Rovo Dev -

--- @@ -142,9 +103,7 @@ yay -S stably-orca-git Annotate any line in an AI-generated diff with your feedback, then send it back to the agent to revise. Keep the review loop tight — no copying line numbers, no context switching.

- Orca Annotate AI Diff — comment on AI-generated diffs and send feedback to the agent -

--- @@ -156,9 +115,7 @@ Annotate any line in an AI-generated diff with your feedback, then send it back If you run multiple Codex accounts to get the best token deal, Orca lets you hot-swap between them instantly — no re-login, no config files. Just pick an account and keep building.

- Orca Codex Account Switcher — hot swap between multiple Codex accounts -

--- @@ -170,9 +127,7 @@ If you run multiple Codex accounts to get the best token deal, Orca lets you hot Orca ships with a built-in browser right inside your worktree. Preview your app as you build, then switch to Design Mode — click any UI element and it lands directly in your AI chat as context. No screenshots, no copy-pasting selectors. Just point at what you want to change and tell the agent what to do.

- Orca Design Mode — click any UI element and drop it into the chat -

--- @@ -200,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 3423258995b..ed58550d249 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -1,45 +1,29 @@

- Orca Orca -

- Plataformas compatibles - Discord - Seguir en X -

- English · 中文 · 日本語 · Español -

- El orquestador de IA para desarrolladores 100x.
- 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. -

- Descargar 🐋 -

- Captura de Orca -

## Agentes compatibles @@ -47,51 +31,28 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).

- Claude Code   - Codex   - Gemini   - Pi   - Hermes Agent   - OpenCode   - Goose   - Amp   - Auggie   - Charm   - Cline   - Codebuff   - Continue   - Cursor   - Droid   - GitHub Copilot   - Kilocode   - Kimi   - Kiro   - Mistral Vibe   - Qwen Code   - Rovo Dev -

--- @@ -142,9 +103,7 @@ yay -S stably-orca-git 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 -

--- @@ -156,9 +115,7 @@ 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 -

--- @@ -170,9 +127,7 @@ Si usas varias cuentas de Codex para aprovechar el mejor precio en tokens, Orca 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 -

--- @@ -200,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 dad29c9f191..d92b0c2f3ed 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -1,45 +1,29 @@

- Orca Orca -

- 対応プラットフォーム - Discord - X でフォロー -

- English · 中文 · 日本語 · Español -

- 100x ビルダーのための AI オーケストレーター。
- Claude Code、Codex、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
- macOS、Windows、Linux で利用できます。 -

- ダウンロード 🐋 -

- Orca Screenshot -

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

- Claude Code   - Codex   - Gemini   - Pi   - Hermes Agent   - OpenCode   - Goose   - Amp   - Auggie   - Charm   - Cline   - Codebuff   - Continue   - Cursor   - Droid   - GitHub Copilot   - Kilocode   - Kimi   - Kiro   - Mistral Vibe   - Qwen Code   - Rovo Dev -

--- @@ -142,9 +103,7 @@ yay -S stably-orca-git AI が生成した Diff の任意の行にフィードバックを付け、そのままエージェントに返して修正させましょう。レビューのループを素早く保てます — 行番号をコピーする必要も、コンテキストを切り替える必要もありません。

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

--- @@ -156,9 +115,7 @@ AI が生成した Diff の任意の行にフィードバックを付け、そ 最適なトークン条件を得るために複数の Codex アカウントを使っている場合、Orca なら再ログインも設定ファイルの編集も不要で、すぐにアカウントをホットスワップできます。アカウントを選んで、そのまま開発を続けられます。

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

--- @@ -170,9 +127,7 @@ AI が生成した Diff の任意の行にフィードバックを付け、そ Orca には、ワークツリー内で使える組み込みブラウザがあります。アプリを作りながらプレビューし、デザインモードに切り替えると、任意の UI 要素をクリックするだけで AI チャットにコンテキストとして直接入ります。スクリーンショットもセレクターのコピーも不要です。変更したい箇所を指して、エージェントに指示するだけです。

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

--- @@ -200,4 +155,4 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli ## 開発について -貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../CONTRIBUTING.md) ガイドをご覧ください。 +貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) ガイドをご覧ください。 diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index f70128969fb..163b1bfb64e 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -1,45 +1,29 @@

- Orca Orca -

- 支持的平台 - Discord - 在 X 上关注 -

- English · 中文 · 日本語 · Español -

- 面向 100x 构建者的 AI 编排器。
- 跨仓库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
- 支持 macOS、Windows 和 Linux。 -

- 下载 🐋 -

- Orca Screenshot -

## 支持的智能体 @@ -47,51 +31,28 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。

- Claude Code   - Codex   - Gemini   - Pi   - Hermes Agent   - OpenCode   - Goose   - Amp   - Auggie   - Charm   - Cline   - Codebuff   - Continue   - Cursor   - Droid   - GitHub Copilot   - Kilocode   - Kimi   - Kiro   - Mistral Vibe   - Qwen Code   - Rovo Dev -

--- @@ -142,9 +103,7 @@ yay -S stably-orca-git 在 AI 生成的 diff 的任意一行添加你的反馈,然后发回给智能体进行修改。让评审循环保持紧凑 — 无需复制行号,也无需切换上下文。

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

--- @@ -156,9 +115,7 @@ yay -S stably-orca-git 如果你使用多个 Codex 账号来获得更合适的 token 配额,Orca 可让你在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。选中账号,然后继续构建。

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

--- @@ -170,9 +127,7 @@ yay -S stably-orca-git Orca 在你的 worktree 中内置了浏览器。边构建边预览应用,然后切换到设计模式 — 点击任意 UI 元素,它会作为上下文直接进入你的 AI 聊天。无需截图,无需复制选择器。只需指向想修改的地方,然后告诉智能体要做什么。

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

--- @@ -200,4 +155,4 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli ## 开发 -想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../CONTRIBUTING.md) 指南。 +想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 指南。 From 6dc2e9e75181390c7e270e3b944e7b4dc0ef56ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 1 May 2026 12:53:45 -0700 Subject: [PATCH 06/10] fix(tabs): align tab row bottom with sidebar header (#1318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tabs): align tab row bottom with sidebar header The center column's top band was 6px drag strip + 34px tab row = 40px, while the sibling `titlebar-left` above the sidebar is 42px. The tab row sat 2px high, making the seam between the two columns visibly off. Bump the drag strip from 6px to 8px so the top band totals 42px and the tab row's bottom border lines up with the sidebar header. Co-authored-by: Orca * fix(tabs): shrink tab row to 32px, pad drag strip to 10px Keep the total top-band at 42px (matching the sidebar's titlebar-left) while making the tabs themselves 2px shorter, so the tab chrome feels a touch more compact without breaking alignment with the sidebar header. Co-authored-by: Orca * fix(tabs): drop doubled border on leftmost split pane The TabGroupSplitLayout wrapper paints a full-height `border-l` at the sidebar seam, and every split pane paints its own `border-l`. On the leftmost pane those stacked at the same x, reading as a ~2px bar just below the drag strip where the wrapper's 1px line continues alone above. Mirror the right-edge handling: skip the pane's `border-l` when it touches the left edge, leaving the single wrapper border to draw the seam — same visual weight as the right edge. Co-authored-by: Orca * docs(tabs): refresh stale wrapper border-l comment Why: `border-l` on the wrapper comment still described the pre-fix overlap behavior. After `touchesLeftEdge`, the leftmost pane drops its own `border-l`, so the seam is exactly 1px — the old comment contradicted the code. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../components/tab-group/TabGroupPanel.tsx | 20 ++++++++++--------- .../tab-group/TabGroupSplitLayout.tsx | 19 +++++++++++------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index b0857bc1498..436acb4ed8e 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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. */}
diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index 75d66d5c953..d0dd373a65d 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -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. */}
From 19c4888e17f2d76e37b65cbe67a6d25222607e32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 1 May 2026 12:56:00 -0700 Subject: [PATCH 07/10] feat(titlebar): Cmd+J search icon next to agent badge (#1314) Co-authored-by: Orca --- src/renderer/src/App.tsx | 23 +++- .../src/components/sidebar/SearchBar.tsx | 41 ------- .../src/components/sidebar/SidebarHeader.tsx | 2 +- .../src/components/sidebar/WorktreeList.tsx | 30 ++--- src/renderer/src/components/sidebar/index.tsx | 2 - .../sidebar/visible-worktrees.test.ts | 10 +- .../components/sidebar/visible-worktrees.ts | 17 +-- .../sidebar/worktree-list-groups.test.ts | 108 +----------------- .../sidebar/worktree-list-groups.ts | 67 ----------- src/renderer/src/lib/worktree-activation.ts | 3 - src/renderer/src/store/selectors.ts | 1 - src/renderer/src/store/slices/ui.ts | 5 - 12 files changed, 35 insertions(+), 274 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/SearchBar.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 634de343e1d..c352e806747 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, @@ -864,6 +864,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/components/sidebar/SearchBar.tsx b/src/renderer/src/components/sidebar/SearchBar.tsx deleted file mode 100644 index b49ec748c32..00000000000 --- a/src/renderer/src/components/sidebar/SearchBar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useCallback } from 'react' -import { Search, X } from 'lucide-react' -import { useAppStore } from '@/store' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' - -const SearchBar = React.memo(function SearchBar() { - const searchQuery = useAppStore((s) => s.searchQuery) - const setSearchQuery = useAppStore((s) => s.setSearchQuery) - - const handleClear = useCallback(() => setSearchQuery(''), [setSearchQuery]) - - return ( -
-
- - setSearchQuery(e.target.value)} - placeholder="Search..." - className="h-7 pl-7 pr-8 text-[12px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30 placeholder:text-muted-foreground/70" - /> - {searchQuery && ( - - )} -
-
- ) -}) - -export default SearchBar diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 3afe12f8309..a943e8cd9cd 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -68,7 +68,7 @@ const SidebarHeader = React.memo(function SidebarHeader() { : PROPERTY_OPTIONS.filter((opt) => opt.id !== 'inline-agents') return ( -
+
Workspaces diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index adcf7ba6a59..ad89d082f74 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 ( diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 4bb1b0d44c9..51f9979a2f8 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -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 */} - diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index c7caa3180e3..160f0af2281 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -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]) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 1d6926f9245..09471b6c97e 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -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 | null browserTabsByWorktree?: Record | null activeWorktreeId?: string | null repoMap: Map - 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 }) } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index fc76d5d996f..129be5f9205 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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' } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 65f048552a5..ba74ed7fd83 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -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, - prCache: Record | null, - issueCache: Record | 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, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2eee77d4a02..f99b8d22843 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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([]) } diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index d188fe90103..fafa467fedf 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -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) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0888ebd0922..946d01bf5f1 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -174,8 +174,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' @@ -417,9 +415,6 @@ export const createUISlice: StateCreator = (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 From d4bbf473c1a4fa8ad53fe51418e5f49125aa2cb7 Mon Sep 17 00:00:00 2001 From: Colin Chambachan Date: Fri, 1 May 2026 16:15:08 -0400 Subject: [PATCH 08/10] Support wiki-style Markdown document links (#1282) Co-authored-by: Orca Co-authored-by: Jinwoo-H --- src/main/ipc/filesystem.test.ts | 150 +++++++++ src/main/ipc/filesystem.ts | 22 ++ src/main/ipc/markdown-documents.ts | 98 ++++++ src/preload/api-types.ts | 5 + src/preload/index.ts | 5 + src/renderer/src/assets/main.css | 7 + src/renderer/src/assets/markdown-preview.css | 15 + .../src/assets/rich-markdown-editor.css | 104 ++++++ .../src/components/editor/EditorContent.tsx | 17 +- .../src/components/editor/MarkdownPreview.tsx | 67 +++- .../src/components/editor/MonacoEditor.tsx | 135 ++++---- .../editor/MonacoGutterContextMenu.tsx | 80 +++++ .../editor/RichMarkdownDocLinkMenu.tsx | 70 ++++ .../components/editor/RichMarkdownEditor.tsx | 102 +++++- .../editor/markdown-doc-completions.test.ts | 47 +++ .../editor/markdown-doc-completions.ts | 43 +++ .../editor/markdown-doc-links.test.ts | 198 +++++++++++ .../components/editor/markdown-doc-links.ts | 220 ++++++++++++ .../editor/markdown-round-trip.test.ts | 27 ++ .../editor/monaco-markdown-doc-completions.ts | 70 ++++ ...naco-markdown-doc-link-decorations.test.ts | 32 ++ .../monaco-markdown-doc-link-decorations.ts | 107 ++++++ .../components/editor/raw-markdown-html.ts | 24 ++ .../editor/rich-markdown-commands.tsx | 132 ++++++++ .../editor/rich-markdown-doc-link.ts | 313 ++++++++++++++++++ .../editor/rich-markdown-extensions.ts | 2 + .../editor/rich-markdown-key-handler.ts | 83 ++++- .../components/editor/useMarkdownDocuments.ts | 141 ++++++++ src/shared/types.ts | 7 + 29 files changed, 2244 insertions(+), 79 deletions(-) create mode 100644 src/main/ipc/markdown-documents.ts create mode 100644 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx create mode 100644 src/renderer/src/components/editor/markdown-doc-completions.test.ts create mode 100644 src/renderer/src/components/editor/markdown-doc-completions.ts create mode 100644 src/renderer/src/components/editor/markdown-doc-links.test.ts create mode 100644 src/renderer/src/components/editor/markdown-doc-links.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-completions.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts create mode 100644 src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-doc-link.ts create mode 100644 src/renderer/src/components/editor/useMarkdownDocuments.ts 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/preload/api-types.ts b/src/preload/api-types.ts index 0c5af49e584..829ea4b023f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -36,6 +36,7 @@ import type { LinearLabel, LinearMember, LinearTeam, + MarkdownDocument, GitHubIssueUpdate, NotificationDispatchRequest, NotificationDispatchResult, @@ -630,6 +631,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 1b6b9ae8f75..a78945c1a23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1100,6 +1100,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/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/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index c9c69fbe60b..fd1f17d709a 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 => handleSave(prependFrontMatter(fm.raw, body)) - : handleSave + ? (body: string): Promise => md.mdSave(prependFrontMatter(fm.raw, body)) + : md.mdSave return (
@@ -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} />
@@ -302,6 +307,7 @@ export function EditorContent({ filePath={activeFile.filePath} scrollCacheKey={markdownPreviewViewStateKey} initialAnchor={activeFile.markdownPreviewAnchor ?? null} + {...md.previewProps} />
) @@ -403,6 +409,7 @@ export function EditorContent({ content={modifiedDiffContent} filePath={activeFile.filePath} scrollCacheKey={`${diffViewStateKey}:preview`} + {...md.previewProps} />
@@ -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} /> ) } diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index ecf96e162d6..a9fd3056483 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -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 } 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(null) const bodyRef = useRef(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): void => { + event.preventDefault() + if (resolvedDocument && onOpenDocument) { + void onOpenDocument(resolvedDocument) + } + } + + return ( + + {children} + + ) + } + const handleClick = (event: React.MouseEvent): void => { if (!href) { return @@ -547,7 +590,13 @@ export default function MarkdownPreview({ } return ( - + {children} ) @@ -644,6 +693,8 @@ export default function MarkdownPreview({ filePath, isDark, isMac, + markdownDocumentIndex, + onOpenDocument, openFile, openMarkdownPreview, scrollToAnchor, @@ -748,7 +799,13 @@ export default function MarkdownPreview({ )} (null) + const modelKeyRef = useRef(null) + const languageRef = useRef(language) + languageRef.current = language + const markdownDocLinkDecorationsRef = useRef(null) const revealDecorationRef = useRef(null) const revealHighlightTimerRef = useRef | null>(null) const revealRafRef = useRef(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 */} - - -
) } diff --git a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx new file mode 100644 index 00000000000..3d9def2b593 --- /dev/null +++ b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx @@ -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 ( + + + + ) + }) + )} + {overflow ? ( +
+ Showing {rows.length} of {totalMatches} +
+ ) : null} +
+ ↑↓ navigate  ↵ select  esc dismiss +
+
+ ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 8b2c25bba78..972440fd603 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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(null) @@ -78,14 +90,20 @@ export default function RichMarkdownEditor({ const scrollContainerRef = useRef(null) const [slashMenu, setSlashMenu] = useState(null) const [selectedCommandIndex, setSelectedCommandIndex] = useState(0) + const [docLinkMenu, setDocLinkMenu] = useState(null) + const [selectedDocLinkIndex, setSelectedDocLinkIndex] = useState(0) const isMac = navigator.userAgent.includes('Mac') const lastCommittedMarkdownRef = useRef(content) const slashMenuRef = useRef(null) const filteredSlashCommandsRef = useRef(slashCommands) const selectedCommandIndexRef = useRef(0) + const docLinkMenuRef = useRef(null) + const filteredDocLinkRowsRef = useRef([]) + 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 ? ( + + ) : null}
) } diff --git a/src/renderer/src/components/editor/markdown-doc-completions.test.ts b/src/renderer/src/components/editor/markdown-doc-completions.test.ts new file mode 100644 index 00000000000..c65386aecb4 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-completions.test.ts @@ -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]]) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-doc-completions.ts b/src/renderer/src/components/editor/markdown-doc-completions.ts new file mode 100644 index 00000000000..094920dd968 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-completions.ts @@ -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)) +} diff --git a/src/renderer/src/components/editor/markdown-doc-links.test.ts b/src/renderer/src/components/editor/markdown-doc-links.test.ts new file mode 100644 index 00000000000..01b43fa7a79 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-links.test.ts @@ -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('other.md') + 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]]' }) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-doc-links.ts b/src/renderer/src/components/editor/markdown-doc-links.ts new file mode 100644 index 00000000000..faffeca6b14 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-doc-links.ts @@ -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 + byRelativePath: Map + byRelativePathWithoutExtension: Map +} + +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, + 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() + const byRelativePath = new Map() + const byRelativePathWithoutExtension = new Map() + + 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) +} diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 20e7c8d64ad..c20c5355f88 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -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```') + }) }) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts new file mode 100644 index 00000000000..882b011e9b3 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -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[1] + +let provider: IDisposable | null = null +let providerMonaco: MonacoApi | null = null +const documentsByModel = new Map() + +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) +} diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts new file mode 100644 index 00000000000..06b23e8a225 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts @@ -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 + } + ]) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts new file mode 100644 index 00000000000..893ae8062a6 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts @@ -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() + } + } +} diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 3b2184d2653..3f53598aaf4 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -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 = /^|^<\/?[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' diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx index a11664e9007..aee9405ce83 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx @@ -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 ") +// 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 ") 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> +): 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 + }) +} diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts new file mode 100644 index 00000000000..f7df555bceb --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -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 } }) => { + const getTarget = (n: { attrs: Record }): 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 }) => { + 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 + ] + } +}) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 9eb32788ba2..00479bfd89f 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -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: { diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index d4734b86f2f..61fc9982e5b 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -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 filteredSlashCommandsRef: MutableRefObject selectedCommandIndexRef: MutableRefObject + docLinkMenuRef: MutableRefObject + filteredDocLinkRowsRef: MutableRefObject + selectedDocLinkIndexRef: MutableRefObject handleLocalImagePickRef: MutableRefObject<() => void> flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> setIsEditingLink: (editing: boolean) => void setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> + setSelectedDocLinkIndex: Dispatch> 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) { diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts new file mode 100644 index 00000000000..f0fdb092c7d --- /dev/null +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -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 + onOpenDocLink: (target: string) => void + previewProps: { + markdownDocuments: MarkdownDocument[] + onOpenDocument: (document: MarkdownDocument) => Promise + } + mdSave: (content: string) => Promise +} + +export function useMarkdownDocuments( + activeFile: OpenFile, + isMarkdown: boolean, + viewMode: MarkdownViewMode, + onSave: (content: string) => Promise +): UseMarkdownDocumentsResult { + const worktreeId = activeFile.worktreeId + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const openFile = useAppStore((s) => s.openFile) + const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState< + Record + >({}) + 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 => { + 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 => { + 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 } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 945b33403a9..302f92e55d3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1202,6 +1202,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' From bbd77c2449cca2beb712d14e6efec2019a1881bc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 1 May 2026 13:25:22 -0700 Subject: [PATCH 09/10] fix(worktree): restore sparse checkout without per-poll git subprocesses (#1316) Co-authored-by: Orca Co-authored-by: Colin --- src/main/git/remove-worktree.test.ts | 241 ++++++++++- src/main/git/status.ts | 2 +- src/main/git/worktree.ts | 152 ++++++- src/main/ipc/repos-sparse-presets.test.ts | 223 ++++++++++ src/main/ipc/repos.ts | 91 +++- src/main/ipc/sparse-checkout-directories.ts | 29 ++ src/main/ipc/worktree-logic.ts | 8 + src/main/ipc/worktree-remote.ts | 63 ++- src/main/ipc/worktrees.test.ts | 174 ++++++++ src/main/persistence.ts | 36 +- src/preload/api-types.ts | 20 +- src/preload/index.ts | 31 +- .../components/NewWorkspaceComposerCard.tsx | 42 +- .../components/NewWorkspaceComposerModal.tsx | 5 +- .../components/settings/RepositoryPane.tsx | 21 + .../settings/SparsePresetSettingsSection.tsx | 335 +++++++++++++++ .../settings/sparse-preset-date.test.ts | 13 + .../components/settings/sparse-preset-date.ts | 16 + .../src/components/sidebar/WorktreeCard.tsx | 28 ++ .../sparse/SparseCheckoutPresetSelect.tsx | 387 ++++++++++++++++++ src/renderer/src/hooks/useComposerState.ts | 134 +++++- src/renderer/src/lib/sparse-paths.ts | 41 ++ .../src/lib/sparse-preset-draft.test.ts | 49 +++ src/renderer/src/lib/sparse-preset-draft.ts | 42 ++ src/renderer/src/store/index.ts | 2 + .../src/store/slices/sparse-presets.test.ts | 233 +++++++++++ .../src/store/slices/sparse-presets.ts | 135 ++++++ .../slices/store-session-cascades.test.ts | 2 + .../src/store/slices/store-test-helpers.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 2 + .../src/store/slices/worktree-helpers.ts | 4 +- src/renderer/src/store/slices/worktrees.ts | 20 +- src/renderer/src/store/types.ts | 2 + src/shared/constants.ts | 1 + src/shared/types.ts | 33 ++ 35 files changed, 2543 insertions(+), 76 deletions(-) create mode 100644 src/main/ipc/repos-sparse-presets.test.ts create mode 100644 src/main/ipc/sparse-checkout-directories.ts create mode 100644 src/renderer/src/components/settings/SparsePresetSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/sparse-preset-date.test.ts create mode 100644 src/renderer/src/components/settings/sparse-preset-date.ts create mode 100644 src/renderer/src/components/sparse/SparseCheckoutPresetSelect.tsx create mode 100644 src/renderer/src/lib/sparse-paths.ts create mode 100644 src/renderer/src/lib/sparse-preset-draft.test.ts create mode 100644 src/renderer/src/lib/sparse-preset-draft.ts create mode 100644 src/renderer/src/store/slices/sparse-presets.test.ts create mode 100644 src/renderer/src/store/slices/sparse-presets.ts 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/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/preload/api-types.ts b/src/preload/api-types.ts index 829ea4b023f..d3cb04832fd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -8,6 +8,7 @@ import type { BrowserSessionProfileSource, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, + CreateWorktreeArgs, CreateWorktreeResult, DirEntry, FsChangedPayload, @@ -46,6 +47,7 @@ import type { PRComment, PRInfo, Repo, + SparsePreset, SearchOptions, SearchResult, StatsSummary, @@ -329,15 +331,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 diff --git a/src/preload/index.ts b/src/preload/index.ts index a78945c1a23..9225b9fa528 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, FsChangedPayload, GitHubAssignableUser, GitHubCommentResult, @@ -246,18 +247,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 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. */} -
+