diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bda260be9aa..87fd0ba92fe 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -245,3 +245,21 @@ jobs:
--draft=false \
--prerelease="$prerelease" \
--repo "$GITHUB_REPOSITORY"
+
+ # Why: dispatch the Homebrew cask bump directly instead of relying on a
+ # `release.published` trigger inside homebrew-bump.yml. GitHub blocks
+ # events raised by GITHUB_TOKEN (like the `gh release edit` above) from
+ # starting other workflows — it's a documented anti-loop safeguard. A
+ # workflow_dispatch from inside an already-running workflow is the
+ # documented escape hatch. Scoped to GA tags only; rc/prerelease tags
+ # should not touch the stable cask.
+ - name: Trigger Homebrew cask bump (stable releases only)
+ if: ${{ !contains(needs.resolve-release.outputs.tag, '-rc.') }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ needs.resolve-release.outputs.tag }}
+ run: |
+ gh workflow run homebrew-bump.yml \
+ --repo "$GITHUB_REPOSITORY" \
+ --ref main \
+ -f tag="$TAG"
diff --git a/README.md b/README.md
index 8c857ad2305..e5624925de9 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,6 @@
-
-
-
-
-
Orca
+
+ Orca
+
@@ -30,7 +28,7 @@
## Supported Agents
-Orca supports any CLI agent (_not just this list_).
+Orca supports any CLI agent (*not just this list*).
Claude Code
@@ -49,7 +47,7 @@ Orca supports any CLI agent (_not just this list_).
Cursor Droid GitHub Copilot
- Kilocode
+ Kilocode Kimi Kiro Mistral Vibe
@@ -73,9 +71,12 @@ Orca supports any CLI agent (_not just this list_).
## Install
-- **[Download from onOrca.dev](https://onOrca.dev)** — or grab the latest binaries from the **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**.
+### Mac, Linux, Windows
-Alternatively, install from a package manager:
+- **[Download from onOrca.dev](https://onOrca.dev)**
+- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
+
+*Alternatively, install from a package manager:*
### macOS (Homebrew)
@@ -93,8 +94,6 @@ yay -S stably-orca-bin
yay -S stably-orca-git
```
-See [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) and [stably-orca-git](https://aur.archlinux.org/packages/stably-orca-git) on the AUR.
-
---
## [New] Annotate AI Diff
@@ -156,4 +155,4 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
## Developing
-Want to contribute or run locally? See our [CONTRIBUTING.md](CONTRIBUTING.md) guide.
+Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
diff --git a/docs/README.es.md b/docs/README.es.md
index 62b6d60c534..ed58550d249 100644
--- a/docs/README.es.md
+++ b/docs/README.es.md
@@ -1,13 +1,11 @@
-
-
-
-
-
Orca
+
+ Orca
+
-
+
-
+
@@ -16,7 +14,7 @@
El orquestador de IA para desarrolladores 100x.
- Corre Claude Code, Codex u OpenCode en paralelo a lo largo de varios repos — cada uno en su propio worktree, todos administrados desde un solo lugar.
+ Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.
@@ -25,12 +23,12 @@
-
+
## Agentes compatibles
-Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
+Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
Claude Code
@@ -49,7 +47,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
Cursor Droid GitHub Copilot
- Kilocode
+ Kilocode Kimi Kiro Mistral Vibe
@@ -63,19 +61,22 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
- **Sin login** — Usa tu propia suscripción de Claude Code o Codex.
- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
-- **Terminales multi-agente** — Corre varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
+- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
+- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
-- **Soporte SSH** — Conéctate a máquinas remotas y corre agentes en ellas directamente desde Orca.
---
## Instalación
-- **[Descarga desde onOrca.dev](https://onOrca.dev)** — o descarga los binarios más recientes desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**.
+### Mac, Linux, Windows
-También puedes instalar desde un gestor de paquetes:
+- **[Descarga desde onOrca.dev](https://onOrca.dev)**
+- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
+
+*También puedes instalar desde un gestor de paquetes:*
### macOS (Homebrew)
@@ -93,8 +94,6 @@ yay -S stably-orca-bin
yay -S stably-orca-git
```
-Consulta [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) y [stably-orca-git](https://aur.archlinux.org/packages/stably-orca-git) en el AUR.
-
---
## [Nuevo] Anotar diff de IA
@@ -104,7 +103,7 @@ Consulta [stably-orca-bin](https://aur.archlinux.org/packages/stably-orca-bin) y
Anota cualquier línea de un diff generado por IA con tus comentarios y mándalo de vuelta al agente para que lo corrija. Mantén el ciclo de revisión bien ajustado — sin copiar números de línea, sin cambiar de contexto.
-
+
---
@@ -116,19 +115,19 @@ Anota cualquier línea de un diff generado por IA con tus comentarios y mándalo
Si usas varias cuentas de Codex para aprovechar el mejor precio en tokens, Orca te permite cambiar entre ellas al instante — sin volver a hacer login, sin tocar archivos de configuración. Elige una cuenta y sigue construyendo.
-
+
---
-## [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.
---
-## [全新] 隆重推出 Orca CLI
+## [全新] Orca CLI 介绍
-**在终端中进行代理编排。**
+**从终端编排智能体。**
-让 AI Agent控制你的Orca IDE。使用 AI 将项目添加到 IDE、启动工作树,并在终端让AI Agent直接更新当前工作树的备注。随 Orca IDE 附带 (在设置中安装)。
+让你的 AI 智能体控制 IDE。使用 AI 将项目添加到 IDE、创建 worktree,并直接从终端更新当前 worktree 的评论,留下有意义的进度检查点。Orca IDE 随附此功能(在设置中安装)。
```bash
npx skills add https://github.com/stablyai/orca --skill orca-cli
@@ -149,11 +148,11 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
- **Discord:** 加入我们的 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
-- **反馈与想法:** 我们发布新功能非常快。需要新功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
-- **支持我们:** 给本仓库点亮 Star,关注我们的日常更新。
+- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
+- **支持我们:** 给这个仓库点 Star,关注我们的日常发布。
---
## 开发
-想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](CONTRIBUTING.md) 指南。
+想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 指南。
diff --git a/package.json b/package.json
index 3e18f27696d..61d0c3f7eac 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.3.26-rc.0",
+ "version": "1.3.26-rc.1",
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts
index eecf11344dc..97f8eee820b 100644
--- a/src/main/git/remove-worktree.test.ts
+++ b/src/main/git/remove-worktree.test.ts
@@ -1,17 +1,38 @@
+/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner
+ mock harness, and splitting them would duplicate setup without a clearer boundary. */
+import type * as FsPromises from 'fs/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({
+const {
+ gitExecFileAsyncMock,
+ gitExecFileSyncMock,
+ translateWslOutputPathsMock,
+ statMock,
+ resolveGitDirMock
+} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
- gitExecFileSyncMock: vi.fn()
+ gitExecFileSyncMock: vi.fn(),
+ translateWslOutputPathsMock: vi.fn((output: string) => output),
+ statMock: vi.fn(),
+ resolveGitDirMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: gitExecFileSyncMock,
- translateWslOutputPaths: (output: string) => output
+ translateWslOutputPaths: translateWslOutputPathsMock
}))
-import { removeWorktree } from './worktree'
+vi.mock('./status', () => ({
+ resolveGitDir: resolveGitDirMock
+}))
+
+vi.mock('fs/promises', async () => {
+ const actual = await vi.importActual('fs/promises')
+ return { ...actual, stat: statMock }
+})
+
+import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree'
type MockResult = {
error?: Error
@@ -54,11 +75,19 @@ describe('removeWorktree', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
+ translateWslOutputPathsMock.mockReset()
+ translateWslOutputPathsMock.mockImplementation((output: string) => output)
+ statMock.mockReset()
+ // Default: no worktree has a sparse-checkout config file. Tests that need
+ // sparse detection override this.
+ statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
+ resolveGitDirMock.mockReset()
+ resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
})
it('removes the worktree, prunes stale refs, and deletes its local branch', async () => {
mockGitCommands({
- 'git worktree list --porcelain': {
+ 'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -68,7 +97,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
- 'git worktree list --porcelain#2': {
+ 'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -92,7 +121,7 @@ branch refs/heads/main
it('skips branch deletion when another worktree still points at the branch', async () => {
mockGitCommands({
- 'git worktree list --porcelain': {
+ 'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -106,7 +135,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
- 'git worktree list --porcelain#2': {
+ 'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -125,7 +154,7 @@ branch refs/heads/feature/test
expect.arrayContaining([
'git worktree remove /repo-feature',
'git worktree prune',
- 'git worktree list --porcelain'
+ 'git worktree list --porcelain -z'
])
)
expect(calls).not.toContain('git branch -D feature/test')
@@ -134,7 +163,7 @@ branch refs/heads/feature/test
it('deletes the branch after prune removes stale sibling worktree entries', async () => {
mockGitCommands({
- 'git worktree list --porcelain': {
+ 'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -149,7 +178,7 @@ branch refs/heads/feature/test
prunable gitdir file points to non-existent location
`
},
- 'git worktree list --porcelain#2': {
+ 'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -172,7 +201,7 @@ branch refs/heads/main
it('passes --force before the worktree path when forced removal is requested', async () => {
mockGitCommands({
- 'git worktree list --porcelain': {
+ 'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -182,7 +211,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
- 'git worktree list --porcelain#2': {
+ 'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -197,7 +226,7 @@ branch refs/heads/main
it('matches Windows worktree paths before deleting the branch', async () => {
mockGitCommands({
- 'git worktree list --porcelain': {
+ 'git worktree list --porcelain -z': {
stdout: `worktree C:/repo
HEAD abc123
branch refs/heads/main
@@ -207,7 +236,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
- 'git worktree list --porcelain#2': {
+ 'git worktree list --porcelain -z#2': {
stdout: `worktree C:/repo
HEAD abc123
branch refs/heads/main
@@ -230,7 +259,7 @@ branch refs/heads/main
it('keeps removal successful when branch cleanup fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGitCommands({
- 'git worktree list --porcelain': {
+ 'git worktree list --porcelain -z': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -240,7 +269,7 @@ HEAD def456
branch refs/heads/feature/test
`
},
- 'git worktree list --porcelain#2': {
+ 'git worktree list --porcelain -z#2': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
@@ -262,3 +291,181 @@ branch refs/heads/main
warnSpy.mockRestore()
})
})
+
+describe('listWorktrees', () => {
+ beforeEach(() => {
+ gitExecFileAsyncMock.mockReset()
+ gitExecFileSyncMock.mockReset()
+ translateWslOutputPathsMock.mockReset()
+ translateWslOutputPathsMock.mockImplementation((output: string) => output)
+ statMock.mockReset()
+ // Default: no worktree has a sparse-checkout config file. Tests that need
+ // sparse detection override this.
+ statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
+ resolveGitDirMock.mockReset()
+ resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
+ })
+
+ it('translates parsed path fields from NUL-delimited porcelain output', async () => {
+ gitExecFileAsyncMock.mockResolvedValueOnce({
+ stdout:
+ 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
+ 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0'
+ })
+ translateWslOutputPathsMock.mockImplementation((output: string) => {
+ expect(output).not.toContain('\0')
+ return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
+ })
+
+ await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([
+ {
+ path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
+ head: 'abc123',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: true
+ },
+ {
+ path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
+ head: 'def456',
+ branch: 'refs/heads/feature/test',
+ isBare: false,
+ isSparse: true,
+ isMainWorktree: false
+ }
+ ])
+ // Why: the non-sparse main worktree gets an fs probe of its sparse config
+ // file; the linked worktree short-circuits on the parsed `sparse` token and
+ // does not. Only one git subprocess runs regardless of worktree count.
+ expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
+ expect(statMock).toHaveBeenCalledTimes(1)
+ expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => {
+ gitExecFileAsyncMock.mockImplementation((args: string[]) => {
+ if (args.join(' ') === 'worktree list --porcelain -z') {
+ return {
+ stdout:
+ 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
+ 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0',
+ stderr: ''
+ }
+ }
+ throw new Error(`Unexpected git call: ${args.join(' ')}`)
+ })
+ translateWslOutputPathsMock.mockImplementation((output: string) => {
+ expect(output).not.toContain('\0')
+ return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
+ })
+ const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature'
+ resolveGitDirMock.mockImplementation(async (worktreePath: string) =>
+ worktreePath === featureWorktreePath
+ ? `${featureWorktreePath}\\.git-worktrees\\feature`
+ : `${worktreePath}/.git`
+ )
+ statMock.mockImplementation(async (filePath: string) => {
+ if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) {
+ return { isFile: () => true, size: 32 }
+ }
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
+ })
+
+ const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')
+
+ expect(worktrees).toEqual([
+ {
+ path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
+ head: 'abc123',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: true
+ },
+ {
+ path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
+ head: 'def456',
+ branch: 'refs/heads/feature/test',
+ isBare: false,
+ isSparse: true,
+ isMainWorktree: false
+ }
+ ])
+ expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath)
+ // Why: the detection path must not spawn a git subprocess per worktree —
+ // the perf regression in #1131 came from `git sparse-checkout list` firing
+ // on every poll.
+ expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
+ })
+})
+
+describe('addSparseWorktree', () => {
+ beforeEach(() => {
+ gitExecFileAsyncMock.mockReset()
+ gitExecFileSyncMock.mockReset()
+ translateWslOutputPathsMock.mockReset()
+ translateWslOutputPathsMock.mockImplementation((output: string) => output)
+ statMock.mockReset()
+ // Default: no worktree has a sparse-checkout config file. Tests that need
+ // sparse detection override this.
+ statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
+ resolveGitDirMock.mockReset()
+ resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
+ })
+
+ it('separates sparse checkout directory operands from options', async () => {
+ gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
+
+ await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src'])
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['sparse-checkout', 'set', '--', '-docs', 'src'],
+ { cwd: '/repo-feature' }
+ )
+ })
+
+ it('removes the worktree and deletes the created branch when sparse setup fails', async () => {
+ mockGitCommands({
+ 'git sparse-checkout set -- packages/web': {
+ error: new Error('sparse setup failed')
+ },
+ 'git worktree list --porcelain -z': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+
+worktree /repo-feature
+HEAD def456
+branch refs/heads/feature/test
+`
+ },
+ 'git worktree list --porcelain -z#2': {
+ stdout: `worktree /repo
+HEAD abc123
+branch refs/heads/main
+`
+ }
+ })
+
+ await expect(
+ addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
+ ).rejects.toThrow('sparse setup failed')
+
+ const calls = getGitCalls()
+ expect(calls).toEqual(
+ expect.arrayContaining([
+ 'git worktree add --no-checkout -b feature/test /repo-feature',
+ 'git sparse-checkout init --cone',
+ 'git sparse-checkout set -- packages/web',
+ 'git worktree remove --force /repo-feature',
+ 'git worktree prune',
+ 'git branch -D feature/test'
+ ])
+ )
+ expectGitCallOrder(
+ calls,
+ 'git sparse-checkout set -- packages/web',
+ 'git worktree remove --force /repo-feature'
+ )
+ expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test')
+ })
+})
diff --git a/src/main/git/status.ts b/src/main/git/status.ts
index 4e62e94f07b..8678cbe749d 100644
--- a/src/main/git/status.ts
+++ b/src/main/git/status.ts
@@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise {
+export async function resolveGitDir(worktreePath: string): Promise {
const dotGitPath = path.join(worktreePath, '.git')
try {
diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts
index c109d168d69..b77b89e33f6 100644
--- a/src/main/git/worktree.ts
+++ b/src/main/git/worktree.ts
@@ -1,6 +1,12 @@
-import { posix, win32 } from 'path'
+import { stat } from 'fs/promises'
+import { join, posix, win32 } from 'path'
import type { GitWorktreeInfo } from '../../shared/types'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
+import { resolveGitDir } from './status'
+
+type SparseWorktreeCreateError = Error & {
+ cleanupFailed?: boolean
+}
function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
@@ -29,21 +35,23 @@ function looksLikeWindowsPath(pathValue: string): boolean {
*/
export function parseWorktreeList(output: string): GitWorktreeInfo[] {
const worktrees: GitWorktreeInfo[] = []
- // [Fix]: Use /\r?\n\r?\n/ to handle both LF and CRLF (\r\n) line endings,
- // which are common when running git on Windows.
- const blocks = output.trim().split(/\r?\n\r?\n/)
+ const blocks = output.includes('\0')
+ ? parseNullDelimitedWorktreeBlocks(output)
+ : output
+ .trim()
+ .split(/\r?\n\r?\n/)
+ .map((block) => block.trim().split(/\r?\n/))
- for (const block of blocks) {
- if (!block.trim()) {
+ for (const lines of blocks) {
+ if (lines.length === 0) {
continue
}
- // [Fix]: Use /\r?\n/ to handle both LF and CRLF (\r\n) line endings.
- const lines = block.trim().split(/\r?\n/)
let path = ''
let head = ''
let branch = ''
let isBare = false
+ let isSparse = false
for (const line of lines) {
if (line.startsWith('worktree ')) {
@@ -54,12 +62,21 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
+ } else if (line === 'sparse') {
+ isSparse = true
}
}
if (path) {
// `git worktree list` always emits the main working tree first.
- worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 })
+ worktrees.push({
+ path,
+ head,
+ branch,
+ isBare,
+ ...(isSparse ? { isSparse } : {}),
+ isMainWorktree: worktrees.length === 0
+ })
}
}
@@ -71,14 +88,24 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
*/
export async function listWorktrees(repoPath: string): Promise {
try {
- const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
+ const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: repoPath
})
- // Why: when git runs inside WSL, worktree paths are Linux-native
- // (e.g. /home/user/repo). Translate them back to Windows UNC paths
- // so the rest of Orca can access them via Node fs APIs.
- const translated = translateWslOutputPaths(stdout, repoPath)
- return parseWorktreeList(translated)
+ // Why: WSL path translation is line-oriented, but `-z` porcelain output is
+ // NUL-delimited. Parse first so only complete path fields are translated.
+ const worktrees = parseWorktreeList(stdout).map((worktree) => {
+ const translatedPath = translateWorktreePath(worktree.path, repoPath)
+ return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
+ })
+ return Promise.all(
+ worktrees.map(async (worktree) => {
+ if (worktree.isBare || worktree.isSparse) {
+ return worktree
+ }
+ const isSparse = await detectSparseCheckout(worktree.path)
+ return isSparse ? { ...worktree, isSparse } : worktree
+ })
+ )
} catch {
return []
}
@@ -96,7 +123,8 @@ export async function addWorktree(
worktreePath: string,
branch: string,
baseBranch?: string,
- refreshLocalBaseRef = false
+ refreshLocalBaseRef = false,
+ noCheckout = false
): Promise {
// Why: Some users want Orca-created worktrees to make plain commands like
// `git diff main...HEAD` work out of the box, while others do not want
@@ -152,13 +180,49 @@ export async function addWorktree(
}
}
- const args = ['worktree', 'add', '-b', branch, worktreePath]
+ const args = ['worktree', 'add']
+ if (noCheckout) {
+ args.push('--no-checkout')
+ }
+ args.push('-b', branch, worktreePath)
if (baseBranch) {
args.push(baseBranch)
}
await gitExecFileAsync(args, { cwd: repoPath })
}
+export async function addSparseWorktree(
+ repoPath: string,
+ worktreePath: string,
+ branch: string,
+ directories: string[],
+ baseBranch?: string,
+ refreshLocalBaseRef = false
+): Promise {
+ let created = false
+ try {
+ await addWorktree(repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, true)
+ created = true
+ await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath })
+ await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath })
+ await gitExecFileAsync(['checkout', branch], { cwd: worktreePath })
+ } catch (error) {
+ const wrapped: SparseWorktreeCreateError =
+ error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error))
+ if (created) {
+ try {
+ await removeWorktree(repoPath, worktreePath, true)
+ } catch {
+ wrapped.cleanupFailed = true
+ // Why: the user needs to know that manual cleanup may be required —
+ // otherwise a half-created worktree silently lingers on disk.
+ wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)`
+ }
+ }
+ throw wrapped
+ }
+}
+
/**
* Remove a worktree.
*/
@@ -208,3 +272,57 @@ export async function removeWorktree(
)
}
}
+
+function parseNullDelimitedWorktreeBlocks(output: string): string[][] {
+ const blocks: string[][] = []
+ let current: string[] = []
+
+ for (const token of output.split('\0')) {
+ if (!token) {
+ if (current.length > 0) {
+ blocks.push(current)
+ current = []
+ }
+ continue
+ }
+ current.push(token)
+ }
+
+ if (current.length > 0) {
+ blocks.push(current)
+ }
+
+ return blocks
+}
+
+function translateWorktreePath(worktreePath: string, repoPath: string): string {
+ const prefix = 'worktree '
+ const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath)
+ return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath
+}
+
+async function detectSparseCheckout(worktreePath: string): Promise {
+ // Why: `listWorktrees` runs on every 3-second git-status poll and on every
+ // worktree refresh, so this probe fires N times per poll for N worktrees.
+ // The previous `git sparse-checkout list` subprocess made that N*poll extra
+ // git processes, which regressed app responsiveness on machines with many
+ // worktrees (see PR #1131 revert in #1290). A single fs.stat on the
+ // per-worktree sparse-checkout config file is ~two orders of magnitude
+ // cheaper and has the same truthiness semantics: Git writes this file when
+ // sparse checkout is enabled for the worktree and does not write it
+ // otherwise.
+ //
+ // Why per-worktree gitdir and not `/.git/info/sparse-checkout`:
+ // linked worktrees have a `.git` file that points at
+ // `/.git/worktrees/`, and that is where Git stores the
+ // worktree-local sparse-checkout config. `core.sparseCheckout` itself is
+ // shared across all worktrees, so the presence of the config file is the
+ // correct per-worktree signal.
+ try {
+ const gitDir = await resolveGitDir(worktreePath)
+ const stats = await stat(join(gitDir, 'info', 'sparse-checkout'))
+ return stats.isFile() && stats.size > 0
+ } catch {
+ return false
+ }
+}
diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts
index 39b348fa04b..4cc0e88d50c 100644
--- a/src/main/ipc/filesystem.test.ts
+++ b/src/main/ipc/filesystem.test.ts
@@ -96,6 +96,27 @@ const REPO_PATH = path.resolve('/workspace/repo')
const WORKSPACE_DIR = path.resolve('/workspace')
const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature')
+type MockDirEntry = {
+ name: string
+ directory?: boolean
+ file?: boolean
+ symlink?: boolean
+}
+
+function dirEntry({ name, directory, file, symlink }: MockDirEntry): {
+ name: string
+ isDirectory: () => boolean
+ isFile: () => boolean
+ isSymbolicLink: () => boolean
+} {
+ return {
+ name,
+ isDirectory: () => directory ?? false,
+ isFile: () => file ?? false,
+ isSymbolicLink: () => symlink ?? false
+ }
+}
+
describe('registerFilesystemHandlers', () => {
const store = {
getRepos: () => [
@@ -311,6 +332,135 @@ describe('registerFilesystemHandlers', () => {
expect(bulkUnstageFilesMock).not.toHaveBeenCalled()
})
+ it('lists markdown documents recursively for a registered worktree', async () => {
+ readdirMock.mockImplementation(async (dirPath: string) => {
+ if (dirPath === WORKTREE_FEATURE_PATH) {
+ return [
+ dirEntry({ name: 'README.md', file: true }),
+ dirEntry({ name: 'docs', directory: true }),
+ dirEntry({ name: 'script.ts', file: true })
+ ]
+ }
+ if (dirPath === path.join(WORKTREE_FEATURE_PATH, 'docs')) {
+ return [
+ dirEntry({ name: 'Guide.MDX', file: true }),
+ dirEntry({ name: 'notes.markdown', file: true })
+ ]
+ }
+ return []
+ })
+
+ registerFilesystemHandlers(store as never)
+
+ await expect(
+ handlers.get('fs:listMarkdownDocuments')!(null, {
+ rootPath: WORKTREE_FEATURE_PATH
+ })
+ ).resolves.toEqual([
+ {
+ filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'Guide.MDX'),
+ relativePath: 'docs/Guide.MDX',
+ basename: 'Guide.MDX',
+ name: 'Guide'
+ },
+ {
+ filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'notes.markdown'),
+ relativePath: 'docs/notes.markdown',
+ basename: 'notes.markdown',
+ name: 'notes'
+ },
+ {
+ filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md'),
+ relativePath: 'README.md',
+ basename: 'README.md',
+ name: 'README'
+ }
+ ])
+ })
+
+ it('skips ignored and symlinked directories when listing markdown documents', async () => {
+ readdirMock.mockImplementation(async (dirPath: string) => {
+ if (dirPath === WORKTREE_FEATURE_PATH) {
+ return [
+ dirEntry({ name: '.git', directory: true }),
+ dirEntry({ name: '.hidden', directory: true }),
+ dirEntry({ name: '.github', directory: true }),
+ dirEntry({ name: 'node_modules', directory: true }),
+ dirEntry({ name: 'linked-docs', directory: true, symlink: true }),
+ dirEntry({ name: 'visible.md', file: true })
+ ]
+ }
+ if (dirPath === path.join(WORKTREE_FEATURE_PATH, '.github')) {
+ return [dirEntry({ name: 'CONTRIBUTING.md', file: true })]
+ }
+ throw new Error(`Unexpected readdir: ${dirPath}`)
+ })
+
+ registerFilesystemHandlers(store as never)
+
+ await expect(
+ handlers.get('fs:listMarkdownDocuments')!(null, {
+ rootPath: WORKTREE_FEATURE_PATH
+ })
+ ).resolves.toEqual([
+ {
+ filePath: path.join(WORKTREE_FEATURE_PATH, '.github', 'CONTRIBUTING.md'),
+ relativePath: '.github/CONTRIBUTING.md',
+ basename: 'CONTRIBUTING.md',
+ name: 'CONTRIBUTING'
+ },
+ {
+ filePath: path.join(WORKTREE_FEATURE_PATH, 'visible.md'),
+ relativePath: 'visible.md',
+ basename: 'visible.md',
+ name: 'visible'
+ }
+ ])
+ })
+
+ it('rejects markdown document listing for authorized but unregistered roots', async () => {
+ registerFilesystemHandlers(store as never)
+
+ await expect(
+ handlers.get('fs:listMarkdownDocuments')!(null, {
+ rootPath: path.resolve('/workspace/unregistered')
+ })
+ ).rejects.toThrow('Access denied: unknown repository or worktree path')
+
+ expect(readdirMock).not.toHaveBeenCalled()
+ })
+
+ it('lists remote markdown documents through the SSH filesystem provider', async () => {
+ const provider = {
+ listFiles: vi
+ .fn()
+ .mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts'])
+ }
+ getSshFilesystemProviderMock.mockReturnValue(provider)
+
+ registerFilesystemHandlers(store as never)
+
+ await expect(
+ handlers.get('fs:listMarkdownDocuments')!(null, {
+ rootPath: '/home/user/project',
+ connectionId: 'ssh-1'
+ })
+ ).resolves.toEqual([
+ {
+ filePath: '/home/user/project/docs/guide.mdx',
+ relativePath: 'docs/guide.mdx',
+ basename: 'guide.mdx',
+ name: 'guide'
+ },
+ {
+ filePath: '/home/user/project/README.md',
+ relativePath: 'README.md',
+ basename: 'README.md',
+ name: 'README'
+ }
+ ])
+ })
+
it('routes branch compare queries through the git compare helper', async () => {
getBranchCompareMock.mockResolvedValue({
summary: {
diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts
index 50bbf5bcf10..2e22c981af5 100644
--- a/src/main/ipc/filesystem.ts
+++ b/src/main/ipc/filesystem.ts
@@ -12,6 +12,7 @@ import type {
GitConflictOperation,
GitDiffResult,
GitStatusResult,
+ MarkdownDocument,
SearchOptions,
SearchResult
} from '../../shared/types'
@@ -47,6 +48,7 @@ import {
import { listQuickOpenFiles } from './filesystem-list-files'
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
import { searchWithGitGrep } from './filesystem-search-git'
+import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents'
import { checkRgAvailable } from './rg-availability'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
@@ -161,6 +163,26 @@ export function registerFilesystemHandlers(store: Store): void {
}
)
+ ipcMain.handle(
+ 'fs:listMarkdownDocuments',
+ async (
+ _event,
+ args: { rootPath: string; connectionId?: string }
+ ): Promise => {
+ if (args.connectionId) {
+ const provider = getSshFilesystemProvider(args.connectionId)
+ if (!provider) {
+ throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
+ }
+ const relativePaths = await provider.listFiles(args.rootPath)
+ return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths)
+ }
+
+ const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store)
+ return listMarkdownDocuments(rootPath)
+ }
+ )
+
ipcMain.handle(
'fs:writeFile',
async (
diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts
new file mode 100644
index 00000000000..cedf854be0b
--- /dev/null
+++ b/src/main/ipc/markdown-documents.ts
@@ -0,0 +1,98 @@
+import { readdir } from 'fs/promises'
+import { basename as pathBasename, extname, join, relative } from 'path'
+import type { MarkdownDocument } from '../../shared/types'
+
+function normalizeRelativePath(path: string): string {
+ return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
+}
+
+function isMarkdownDocumentName(name: string): boolean {
+ const extension = extname(name).toLowerCase()
+ return extension === '.md' || extension === '.mdx' || extension === '.markdown'
+}
+
+function basenameFromRelativePath(relativePath: string): string {
+ const normalizedPath = relativePath.replaceAll('\\', '/')
+ return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
+}
+
+function isSafeRelativePath(relativePath: string): boolean {
+ return !relativePath.split('/').includes('..')
+}
+
+function toMarkdownDocument(rootPath: string, filePath: string): MarkdownDocument {
+ const basename = pathBasename(filePath)
+ const extension = extname(basename)
+ return {
+ filePath,
+ relativePath: normalizeRelativePath(relative(rootPath, filePath)),
+ basename,
+ name: extension ? basename.slice(0, -extension.length) : basename
+ }
+}
+
+export function markdownDocumentFromRelativePath(
+ rootPath: string,
+ relativePath: string
+): MarkdownDocument | null {
+ const normalizedRelativePath = normalizeRelativePath(relativePath)
+ // Why: SSH providers should return root-relative paths; reject escape
+ // segments before building a synthetic absolute path for renderer use.
+ if (!isSafeRelativePath(normalizedRelativePath)) {
+ return null
+ }
+ const basename = basenameFromRelativePath(normalizedRelativePath)
+ if (!isMarkdownDocumentName(basename)) {
+ return null
+ }
+ const extension = extname(basename)
+ const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
+ return {
+ filePath: `${normalizedRoot}/${normalizedRelativePath}`,
+ relativePath: normalizedRelativePath,
+ basename,
+ name: extension ? basename.slice(0, -extension.length) : basename
+ }
+}
+
+export function markdownDocumentsFromRelativePaths(
+ rootPath: string,
+ relativePaths: string[]
+): MarkdownDocument[] {
+ return relativePaths
+ .map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath))
+ .filter((document): document is MarkdownDocument => document !== null)
+ .sort((a, b) => a.relativePath.localeCompare(b.relativePath))
+}
+
+export async function listMarkdownDocuments(rootPath: string): Promise {
+ const documents: MarkdownDocument[] = []
+
+ async function visitDirectory(dirPath: string): Promise {
+ const entries = await readdir(dirPath, { withFileTypes: true })
+ for (const entry of entries) {
+ if (entry.isSymbolicLink()) {
+ continue
+ }
+
+ const entryPath = join(dirPath, entry.name)
+ if (entry.isDirectory()) {
+ if (entry.name === '.git' || entry.name === 'node_modules') {
+ continue
+ }
+ if (entry.name.startsWith('.') && entry.name !== '.github') {
+ continue
+ }
+ await visitDirectory(entryPath)
+ continue
+ }
+
+ if (entry.isFile() && isMarkdownDocumentName(entry.name)) {
+ documents.push(toMarkdownDocument(rootPath, entryPath))
+ }
+ }
+ }
+
+ await visitDirectory(rootPath)
+ return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
+}
diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts
new file mode 100644
index 00000000000..71ab88b6a4d
--- /dev/null
+++ b/src/main/ipc/repos-sparse-presets.test.ts
@@ -0,0 +1,223 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as CryptoModule from 'crypto'
+import type { SparsePreset } from '../../shared/types'
+
+const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({
+ handleMock: vi.fn(),
+ randomUUIDMock: vi.fn(() => 'preset-new'),
+ mockStore: {
+ getRepos: vi.fn().mockReturnValue([]),
+ addRepo: vi.fn(),
+ removeRepo: vi.fn(),
+ getRepo: vi.fn(),
+ updateRepo: vi.fn(),
+ getSparsePresets: vi.fn(),
+ saveSparsePreset: vi.fn(),
+ removeSparsePreset: vi.fn()
+ }
+}))
+
+vi.mock('crypto', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ randomUUID: randomUUIDMock
+ }
+})
+
+vi.mock('electron', () => ({
+ dialog: { showOpenDialog: vi.fn() },
+ ipcMain: {
+ handle: handleMock,
+ removeHandler: vi.fn()
+ }
+}))
+
+vi.mock('../git/repo', () => ({
+ isGitRepo: vi.fn().mockReturnValue(true),
+ getGitUsername: vi.fn().mockReturnValue(''),
+ getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
+ getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
+ searchBaseRefs: vi.fn().mockResolvedValue([]),
+ BASE_REF_SEARCH_ARGS: ['for-each-ref'],
+ filterBaseRefSearchOutput: vi.fn().mockReturnValue([])
+}))
+
+vi.mock('./filesystem-auth', () => ({
+ rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined)
+}))
+
+vi.mock('../providers/ssh-git-dispatch', () => ({
+ getSshGitProvider: vi.fn()
+}))
+
+vi.mock('./ssh', () => ({
+ getActiveMultiplexer: vi.fn()
+}))
+
+import { registerRepoHandlers } from './repos'
+
+type HandlerMap = Map unknown>
+
+function makePreset(
+ overrides: Partial & { id: string; repoId: string }
+): SparsePreset {
+ return {
+ name: overrides.id,
+ directories: ['packages/web'],
+ createdAt: 10,
+ updatedAt: 20,
+ ...overrides
+ }
+}
+
+describe('sparse preset repo IPC handlers', () => {
+ const handlers: HandlerMap = new Map()
+ const mainWindow = {
+ isDestroyed: () => false,
+ webContents: { send: vi.fn() }
+ }
+
+ beforeEach(() => {
+ handlers.clear()
+ handleMock.mockReset()
+ handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
+ handlers.set(channel, handler)
+ })
+ randomUUIDMock.mockReturnValue('preset-new')
+ mainWindow.webContents.send.mockReset()
+ mockStore.getRepo.mockReset().mockReturnValue({
+ id: 'repo-1',
+ path: '/repo',
+ displayName: 'repo',
+ badgeColor: '#000',
+ addedAt: 0
+ })
+ mockStore.getSparsePresets.mockReset().mockReturnValue([])
+ mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset)
+ mockStore.removeSparsePreset.mockReset()
+
+ registerRepoHandlers(mainWindow as never, mockStore as never)
+ })
+
+ it('normalizes and de-duplicates saved sparse preset directories', () => {
+ const saved = handlers.get('sparsePresets:save')!(null, {
+ repoId: 'repo-1',
+ name: ' Web preset ',
+ directories: [' packages/web ', 'apps\\api\\', 'packages/web/', '.', '']
+ })
+
+ expect(mockStore.saveSparsePreset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'preset-new',
+ repoId: 'repo-1',
+ name: 'Web preset',
+ directories: ['packages/web', 'apps/api']
+ })
+ )
+ expect(saved).toEqual(
+ expect.objectContaining({
+ name: 'Web preset',
+ directories: ['packages/web', 'apps/api']
+ })
+ )
+ expect(mainWindow.webContents.send).toHaveBeenCalledWith('sparsePresets:changed', {
+ repoId: 'repo-1'
+ })
+ })
+
+ it('preserves createdAt and id when editing an existing preset', () => {
+ mockStore.getSparsePresets.mockReturnValue([
+ makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Old', createdAt: 123 })
+ ])
+
+ const saved = handlers.get('sparsePresets:save')!(null, {
+ repoId: 'repo-1',
+ id: 'preset-1',
+ name: 'New',
+ directories: ['packages/new']
+ })
+
+ expect(saved).toEqual(
+ expect.objectContaining({
+ id: 'preset-1',
+ name: 'New',
+ createdAt: 123,
+ directories: ['packages/new']
+ })
+ )
+ })
+
+ it('rejects invalid sparse preset saves before persistence', () => {
+ expect(() =>
+ handlers.get('sparsePresets:save')!(null, {
+ repoId: 'repo-1',
+ name: 'Web',
+ directories: ['../secrets']
+ })
+ ).toThrow('Preset directories must be repo-relative paths.')
+
+ expect(() =>
+ handlers.get('sparsePresets:save')!(null, {
+ repoId: 'repo-1',
+ name: ' ',
+ directories: ['packages/web']
+ })
+ ).toThrow('Preset name is required.')
+
+ expect(() =>
+ handlers.get('sparsePresets:save')!(null, {
+ repoId: 'repo-1',
+ name: 'Web',
+ directories: ['.', '']
+ })
+ ).toThrow('Preset must have at least one directory.')
+
+ expect(mockStore.saveSparsePreset).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ '/Users/me/repo/packages/web',
+ 'C:\\repo\\packages\\web',
+ '\\\\server\\share\\repo',
+ '\\repo\\packages\\web'
+ ])('rejects absolute sparse preset directory before normalization: %s', (directory) => {
+ expect(() =>
+ handlers.get('sparsePresets:save')!(null, {
+ repoId: 'repo-1',
+ name: 'Web',
+ directories: ['packages/web', directory]
+ })
+ ).toThrow('Preset directories must be repo-relative paths.')
+
+ expect(mockStore.saveSparsePreset).not.toHaveBeenCalled()
+ })
+
+ it('rejects sparse preset saves for unknown repos', () => {
+ mockStore.getRepo.mockReturnValue(null)
+
+ expect(() =>
+ handlers.get('sparsePresets:save')!(null, {
+ repoId: 'missing',
+ name: 'Web',
+ directories: ['packages/web']
+ })
+ ).toThrow('Repo "missing" not found')
+ })
+
+ it('rejects sparse preset removals for unknown repos before persistence', () => {
+ mockStore.getRepo.mockReturnValue(null)
+
+ expect(() =>
+ handlers.get('sparsePresets:remove')!(null, {
+ repoId: 'missing',
+ presetId: 'preset-1'
+ })
+ ).toThrow('Repo "missing" not found')
+
+ expect(mockStore.removeSparsePreset).not.toHaveBeenCalled()
+ expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('sparsePresets:changed', {
+ repoId: 'missing'
+ })
+ })
+})
diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts
index b59aa8fb7ca..3f1b13df5d2 100644
--- a/src/main/ipc/repos.ts
+++ b/src/main/ipc/repos.ts
@@ -5,7 +5,7 @@ import type { BrowserWindow } from 'electron'
import { dialog, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
-import type { Repo, BaseRefDefaultResult } from '../../shared/types'
+import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { REPO_COLORS } from '../../shared/constants'
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
@@ -28,6 +28,7 @@ import {
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getActiveMultiplexer } from './ssh'
+import { normalizeSparseDirectories } from './sparse-checkout-directories'
// Why: module-scoped so the abort handle survives window re-creation on macOS.
// registerRepoHandlers is called again when a new BrowserWindow is created,
@@ -50,6 +51,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
ipcMain.removeHandler('repos:addRemote')
+ ipcMain.removeHandler('sparsePresets:list')
+ ipcMain.removeHandler('sparsePresets:save')
+ ipcMain.removeHandler('sparsePresets:remove')
ipcMain.handle('repos:list', () => {
return store.getRepos()
@@ -219,6 +223,55 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
+ // ── Sparse presets ─────────────────────────────────────────────
+ // Why: presets are repo-scoped reusable directory lists used by the
+ // new-workspace composer. Persisted via Store and broadcast back to the
+ // renderer so any open composer reflects new/edited/deleted presets
+ // immediately.
+
+ ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => {
+ return store.getSparsePresets(args.repoId)
+ })
+
+ ipcMain.handle(
+ 'sparsePresets:save',
+ (
+ _event,
+ args: { repoId: string; id?: string; name: string; directories: string[] }
+ ): SparsePreset => {
+ const repo = store.getRepo(args.repoId)
+ if (!repo) {
+ throw new Error(`Repo "${args.repoId}" not found`)
+ }
+ const name = normalizeSparsePresetName(args.name)
+ const directories = normalizeSparsePresetDirectories(args.directories)
+ const now = Date.now()
+ const existing = args.id
+ ? store.getSparsePresets(args.repoId).find((preset) => preset.id === args.id)
+ : undefined
+ const preset: SparsePreset = {
+ id: existing?.id ?? randomUUID(),
+ repoId: args.repoId,
+ name,
+ directories,
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now
+ }
+ const saved = store.saveSparsePreset(preset)
+ notifySparsePresetsChanged(mainWindow, args.repoId)
+ return saved
+ }
+ )
+
+ ipcMain.handle('sparsePresets:remove', (_event, args: { repoId: string; presetId: string }) => {
+ const repo = store.getRepo(args.repoId)
+ if (!repo) {
+ throw new Error(`Repo "${args.repoId}" not found`)
+ }
+ store.removeSparsePreset(args.repoId, args.presetId)
+ notifySparsePresetsChanged(mainWindow, args.repoId)
+ })
+
ipcMain.handle('repos:pickFolder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
@@ -514,3 +567,39 @@ function notifyReposChanged(mainWindow: BrowserWindow): void {
mainWindow.webContents.send('repos:changed')
}
}
+
+function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void {
+ if (!mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('sparsePresets:changed', { repoId })
+ }
+}
+
+function normalizeSparsePresetName(name: string): string {
+ const trimmed = name.trim()
+ if (!trimmed) {
+ throw new Error('Preset name is required.')
+ }
+ if (trimmed.length > 80) {
+ throw new Error('Preset name is too long.')
+ }
+ return trimmed
+}
+
+function normalizeSparsePresetDirectories(directories: string[]): string[] {
+ let normalized: string[]
+ try {
+ normalized = normalizeSparseDirectories(directories)
+ } catch (err) {
+ if (
+ err instanceof Error &&
+ err.message === 'Sparse checkout directories must be repo-relative paths.'
+ ) {
+ throw new Error('Preset directories must be repo-relative paths.')
+ }
+ throw err
+ }
+ if (normalized.length === 0) {
+ throw new Error('Preset must have at least one directory.')
+ }
+ return normalized
+}
diff --git a/src/main/ipc/sparse-checkout-directories.ts b/src/main/ipc/sparse-checkout-directories.ts
new file mode 100644
index 00000000000..74f2873d062
--- /dev/null
+++ b/src/main/ipc/sparse-checkout-directories.ts
@@ -0,0 +1,29 @@
+const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
+
+function isAbsoluteSparseDirectoryPath(entry: string): boolean {
+ return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
+}
+
+export function normalizeSparseDirectories(directories: string[]): string[] {
+ const seen = new Set()
+ return directories
+ .map((entry) => entry.trim())
+ .map((entry) => {
+ // Why: absolute paths can look repo-relative after slash normalization.
+ if (isAbsoluteSparseDirectoryPath(entry)) {
+ throw new Error('Sparse checkout directories must be repo-relative paths.')
+ }
+ return entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
+ })
+ .filter((entry) => entry.length > 0 && entry !== '.')
+ .filter((entry) => {
+ if (entry.split('/').includes('..')) {
+ throw new Error('Sparse checkout directories must be repo-relative paths.')
+ }
+ if (seen.has(entry)) {
+ return false
+ }
+ seen.add(entry)
+ return true
+ })
+}
diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts
index 70e60f2ef44..f4fc0e292b8 100644
--- a/src/main/ipc/worktree-logic.ts
+++ b/src/main/ipc/worktree-logic.ts
@@ -160,6 +160,7 @@ export function mergeWorktree(
head: git.head,
branch: git.branch,
isBare: git.isBare,
+ ...(git.isSparse === true ? { isSparse: true } : {}),
isMainWorktree: git.isMainWorktree,
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
comment: meta?.comment || '',
@@ -171,6 +172,13 @@ export function mergeWorktree(
isPinned: meta?.isPinned ?? false,
sortOrder: meta?.sortOrder ?? 0,
lastActivityAt: meta?.lastActivityAt ?? 0,
+ ...(git.isSparse === true
+ ? {
+ sparseDirectories: meta?.sparseDirectories,
+ sparseBaseRef: meta?.sparseBaseRef,
+ sparsePresetId: meta?.sparsePresetId
+ }
+ : {}),
// Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in
// shared/types) and forwarded verbatim so the renderer store mirrors
// on-disk state. `undefined` here means the worktree has no comments yet.
diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts
index 548dcc7cead..31867bbc6b6 100644
--- a/src/main/ipc/worktree-remote.ts
+++ b/src/main/ipc/worktree-remote.ts
@@ -13,7 +13,7 @@ import type {
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
-import { listWorktrees, addWorktree } from '../git/worktree'
+import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
import { gitExecFileAsync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
@@ -31,6 +31,7 @@ import {
areWorktreePathsEqual
} from './worktree-logic'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
+import { normalizeSparseDirectories } from './sparse-checkout-directories'
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
@@ -44,6 +45,10 @@ export async function createRemoteWorktree(
store: Store,
mainWindow: BrowserWindow
): Promise {
+ if (args.sparseCheckout) {
+ throw new Error('Sparse checkout is not supported for remote SSH repos yet.')
+ }
+
const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined
if (!provider) {
throw new Error(`No git provider for connection "${repo.connectionId}"`)
@@ -307,6 +312,32 @@ export async function createLocalWorktree(
// Resolve it before mutating git state so missing UI input cannot strand
// a real worktree on disk while the renderer reports "create failed".
const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false
+ const sparseDirectories = args.sparseCheckout
+ ? normalizeSparseDirectories(args.sparseCheckout.directories)
+ : []
+ if (args.sparseCheckout && sparseDirectories.length === 0) {
+ throw new Error('Sparse checkout requires at least one repo-relative directory.')
+ }
+ let sparsePresetId: string | undefined
+ if (args.sparseCheckout?.presetId) {
+ const preset = store
+ .getSparsePresets(repo.id)
+ .find((entry) => entry.id === args.sparseCheckout?.presetId)
+ if (preset?.repoId === repo.id) {
+ try {
+ const presetDirectories = normalizeSparseDirectories(preset.directories)
+ // Why: use Set-based comparison so directory order does not affect
+ // attribution — matches the renderer's sparseDirectoriesMatch logic.
+ const presetSet = new Set(presetDirectories)
+ const directoriesMatch =
+ presetDirectories.length === sparseDirectories.length &&
+ sparseDirectories.every((entry) => presetSet.has(entry))
+ sparsePresetId = directoriesMatch ? preset.id : undefined
+ } catch {
+ // Why: corrupt preset data should not block creation or falsely label the new worktree.
+ }
+ }
+ }
// Why: `git fetch` previously blocked worktree creation for 1–5s on every
// click, even though the fetch result isn't actually required — the
@@ -319,13 +350,22 @@ export async function createLocalWorktree(
// Fetch is best-effort — don't block worktree creation if offline
})
- await addWorktree(
- repo.path,
- worktreePath,
- branchName,
- baseBranch,
- settings.refreshLocalBaseRefOnWorktreeCreate
- )
+ await (sparseDirectories.length > 0
+ ? addSparseWorktree(
+ repo.path,
+ worktreePath,
+ branchName,
+ sparseDirectories,
+ baseBranch,
+ settings.refreshLocalBaseRefOnWorktreeCreate
+ )
+ : addWorktree(
+ repo.path,
+ worktreePath,
+ branchName,
+ baseBranch,
+ settings.refreshLocalBaseRefOnWorktreeCreate
+ ))
// Re-list to get the freshly created worktree info
const gitWorktrees = await listWorktrees(repo.path)
@@ -342,6 +382,13 @@ export async function createLocalWorktree(
lastActivityAt: Date.now(),
...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
+ : {}),
+ ...(sparseDirectories.length > 0
+ ? {
+ sparseDirectories,
+ sparseBaseRef: baseBranch,
+ sparsePresetId
+ }
: {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts
index 0bbba1a6a48..f32730d0b33 100644
--- a/src/main/ipc/worktrees.test.ts
+++ b/src/main/ipc/worktrees.test.ts
@@ -6,6 +6,7 @@ const {
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
+ addSparseWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
@@ -26,6 +27,7 @@ const {
removeHandlerMock: vi.fn(),
listWorktreesMock: vi.fn(),
addWorktreeMock: vi.fn(),
+ addSparseWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
getGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
@@ -53,6 +55,7 @@ vi.mock('electron', () => ({
vi.mock('../git/worktree', () => ({
listWorktrees: listWorktreesMock,
addWorktree: addWorktreeMock,
+ addSparseWorktree: addSparseWorktreeMock,
removeWorktree: removeWorktreeMock
}))
@@ -113,6 +116,7 @@ describe('registerWorktreeHandlers', () => {
const store = {
getRepos: vi.fn(),
getRepo: vi.fn(),
+ getSparsePresets: vi.fn(),
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
@@ -125,6 +129,7 @@ describe('registerWorktreeHandlers', () => {
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
+ addSparseWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
@@ -143,6 +148,7 @@ describe('registerWorktreeHandlers', () => {
mainWindow.webContents.send,
store.getRepos,
store.getRepo,
+ store.getSparsePresets,
store.getSettings,
store.getWorktreeMeta,
store.setWorktreeMeta,
@@ -168,6 +174,7 @@ describe('registerWorktreeHandlers', () => {
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
+ store.getSparsePresets.mockReturnValue([])
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
@@ -471,6 +478,173 @@ describe('registerWorktreeHandlers', () => {
)
})
+ it('creates a sparse worktree and persists its sparse metadata', async () => {
+ listWorktreesMock.mockResolvedValue([
+ {
+ ...createdWorktreeList[0],
+ isSparse: true
+ }
+ ])
+ store.setWorktreeMeta.mockReturnValue({
+ sparseDirectories: ['packages/web', 'apps/api'],
+ sparseBaseRef: 'origin/main',
+ sparsePresetId: 'preset-1'
+ })
+ store.getSparsePresets.mockReturnValue([
+ {
+ id: 'preset-1',
+ repoId: 'repo-1',
+ name: 'Frontend and API',
+ directories: ['packages/web', 'apps/api'],
+ createdAt: 1,
+ updatedAt: 1
+ }
+ ])
+
+ const result = await handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ sparseCheckout: {
+ directories: [' packages/web ', 'apps\\api\\', 'packages/web/'],
+ presetId: 'preset-1'
+ }
+ })
+
+ expect(addWorktreeMock).not.toHaveBeenCalled()
+ expect(addSparseWorktreeMock).toHaveBeenCalledWith(
+ '/workspace/repo',
+ '/workspace/improve-dashboard',
+ 'improve-dashboard',
+ ['packages/web', 'apps/api'],
+ 'origin/main',
+ false
+ )
+ expect(store.setWorktreeMeta).toHaveBeenCalledWith(
+ 'repo-1::/workspace/improve-dashboard',
+ expect.objectContaining({
+ sparseDirectories: ['packages/web', 'apps/api'],
+ sparseBaseRef: 'origin/main',
+ sparsePresetId: 'preset-1'
+ })
+ )
+ expect(result).toEqual({
+ worktree: expect.objectContaining({
+ repoId: 'repo-1',
+ path: '/workspace/improve-dashboard',
+ sparseDirectories: ['packages/web', 'apps/api'],
+ sparseBaseRef: 'origin/main',
+ sparsePresetId: 'preset-1'
+ })
+ })
+ })
+
+ it('clears sparse preset attribution when the preset id does not belong to the repo', async () => {
+ listWorktreesMock.mockResolvedValue([
+ {
+ ...createdWorktreeList[0],
+ isSparse: true
+ }
+ ])
+ store.getSparsePresets.mockReturnValue([
+ {
+ id: 'preset-2',
+ repoId: 'repo-1',
+ name: 'Other preset',
+ directories: ['packages/web'],
+ createdAt: 1,
+ updatedAt: 1
+ }
+ ])
+
+ await handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ sparseCheckout: {
+ directories: ['packages/web'],
+ presetId: 'preset-1'
+ }
+ })
+
+ expect(store.setWorktreeMeta).toHaveBeenCalledWith(
+ 'repo-1::/workspace/improve-dashboard',
+ expect.objectContaining({
+ sparseDirectories: ['packages/web'],
+ sparseBaseRef: 'origin/main',
+ sparsePresetId: undefined
+ })
+ )
+ })
+
+ it('clears sparse preset attribution when normalized directories do not match', async () => {
+ listWorktreesMock.mockResolvedValue([
+ {
+ ...createdWorktreeList[0],
+ isSparse: true
+ }
+ ])
+ store.getSparsePresets.mockReturnValue([
+ {
+ id: 'preset-1',
+ repoId: 'repo-1',
+ name: 'Frontend and API',
+ directories: ['packages/web', 'apps/api'],
+ createdAt: 1,
+ updatedAt: 1
+ }
+ ])
+
+ await handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ sparseCheckout: {
+ directories: ['packages/web'],
+ presetId: 'preset-1'
+ }
+ })
+
+ expect(store.setWorktreeMeta).toHaveBeenCalledWith(
+ 'repo-1::/workspace/improve-dashboard',
+ expect.objectContaining({
+ sparseDirectories: ['packages/web'],
+ sparseBaseRef: 'origin/main',
+ sparsePresetId: undefined
+ })
+ )
+ })
+
+ it('rejects sparse checkout directories that traverse above the repo root', async () => {
+ await expect(
+ handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ sparseCheckout: {
+ directories: ['packages/web', '../secrets']
+ }
+ })
+ ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
+
+ expect(addSparseWorktreeMock).not.toHaveBeenCalled()
+ expect(addWorktreeMock).not.toHaveBeenCalled()
+ })
+
+ it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
+ 'rejects absolute sparse checkout directory before normalization: %s',
+ async (directory) => {
+ await expect(
+ handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ sparseCheckout: {
+ directories: ['packages/web', directory]
+ }
+ })
+ ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
+
+ expect(addSparseWorktreeMock).not.toHaveBeenCalled()
+ expect(addWorktreeMock).not.toHaveBeenCalled()
+ }
+ )
+
it('still returns the created worktree when setup runner generation fails', async () => {
listWorktreesMock.mockResolvedValue(createdWorktreeList)
getEffectiveHooksMock.mockReturnValue({
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index 0e9f89b8dc8..578c926c2e4 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -6,7 +6,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkS
import { writeFile, rename, mkdir, rm } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
-import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
+import type {
+ PersistedState,
+ Repo,
+ SparsePreset,
+ WorktreeMeta,
+ GlobalSettings
+} from '../shared/types'
import type { SshTarget } from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
import { getGitUsername } from './git/repo'
@@ -343,6 +349,9 @@ export class Store {
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
+ // Why: presets are repo-scoped, so removing the repo means the presets
+ // can never be referenced again — drop them with the parent.
+ delete this.state.sparsePresetsByRepo[id]
// Clean up worktree meta for this repo
const prefix = `${id}::`
for (const key of Object.keys(this.state.worktreeMeta)) {
@@ -393,6 +402,31 @@ export class Store {
}
}
+ // ── Sparse Presets ─────────────────────────────────────────────────
+
+ getSparsePresets(repoId: string): SparsePreset[] {
+ return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
+ left.name.localeCompare(right.name)
+ )
+ }
+
+ saveSparsePreset(preset: SparsePreset): SparsePreset {
+ const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? []
+ const index = existing.findIndex((entry) => entry.id === preset.id)
+ this.state.sparsePresetsByRepo[preset.repoId] =
+ index === -1
+ ? [...existing, preset]
+ : existing.map((entry, i) => (i === index ? preset : entry))
+ this.scheduleSave()
+ return preset
+ }
+
+ removeSparsePreset(repoId: string, presetId: string): void {
+ const existing = this.state.sparsePresetsByRepo[repoId] ?? []
+ this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId)
+ this.scheduleSave()
+ }
+
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
diff --git a/src/main/providers/process-cwd.ts b/src/main/providers/process-cwd.ts
index 3ff4a252895..326a61b426d 100644
--- a/src/main/providers/process-cwd.ts
+++ b/src/main/providers/process-cwd.ts
@@ -70,7 +70,11 @@ async function doResolve(pid: number): Promise {
}
try {
- const { stdout } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], {
+ // Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them
+ // and emits cwd records for every process on the system, so the n-line
+ // scan below picks up the first unrelated process (often pid ~391 with
+ // cwd `/`) and returns `/` regardless of the target pid's real cwd.
+ const { stdout } = await execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
encoding: 'utf-8',
timeout: LSOF_TIMEOUT_MS
})
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 4f96d74fd6f..8c59e0c384f 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -8,6 +8,7 @@ import type {
BrowserSessionProfileSource,
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
+ CreateWorktreeArgs,
CreateWorktreeResult,
CustomPetModel,
DirEntry,
@@ -37,6 +38,7 @@ import type {
LinearLabel,
LinearMember,
LinearTeam,
+ MarkdownDocument,
GitHubIssueUpdate,
NotificationDispatchRequest,
NotificationDispatchResult,
@@ -46,6 +48,7 @@ import type {
PRComment,
PRInfo,
Repo,
+ SparsePreset,
SearchOptions,
SearchResult,
StatsSummary,
@@ -329,15 +332,21 @@ export type PreloadApi = {
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise
onChanged: (callback: () => void) => () => void
}
+ sparsePresets: {
+ list: (args: { repoId: string }) => Promise
+ save: (args: {
+ repoId: string
+ id?: string
+ name: string
+ directories: string[]
+ }) => Promise
+ remove: (args: { repoId: string; presetId: string }) => Promise
+ onChanged: (callback: (data: { repoId: string }) => void) => () => void
+ }
worktrees: {
list: (args: { repoId: string }) => Promise
listAll: () => Promise
- create: (args: {
- repoId: string
- name: string
- baseBranch?: string
- setupDecision?: 'inherit' | 'run' | 'skip'
- }) => Promise
+ create: (args: CreateWorktreeArgs) => Promise
resolvePrBase: (args: {
repoId: string
prNumber: number
@@ -636,6 +645,10 @@ export type PreloadApi = {
filePath: string
connectionId?: string
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
+ listMarkdownDocuments: (args: {
+ rootPath: string
+ connectionId?: string
+ }) => Promise
writeFile: (args: { filePath: string; content: string; connectionId?: string }) => Promise
createFile: (args: { filePath: string; connectionId?: string }) => Promise
createDir: (args: { dirPath: string; connectionId?: string }) => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 6ea52d9d292..cfd63c70fdf 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -9,6 +9,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type {
BaseRefDefaultResult,
+ CreateWorktreeArgs,
CustomPetModel,
FsChangedPayload,
GitHubAssignableUser,
@@ -247,18 +248,36 @@ const api = {
}
},
+ sparsePresets: {
+ list: (args: { repoId: string }): Promise =>
+ ipcRenderer.invoke('sparsePresets:list', args),
+
+ save: (args: {
+ repoId: string
+ id?: string
+ name: string
+ directories: string[]
+ }): Promise => ipcRenderer.invoke('sparsePresets:save', args),
+
+ remove: (args: { repoId: string; presetId: string }): Promise =>
+ ipcRenderer.invoke('sparsePresets:remove', args),
+
+ onChanged: (callback: (data: { repoId: string }) => void): (() => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) =>
+ callback(data)
+ ipcRenderer.on('sparsePresets:changed', listener)
+ return () => ipcRenderer.removeListener('sparsePresets:changed', listener)
+ }
+ },
+
worktrees: {
list: (args: { repoId: string }): Promise =>
ipcRenderer.invoke('worktrees:list', args),
listAll: (): Promise => ipcRenderer.invoke('worktrees:listAll'),
- create: (args: {
- repoId: string
- name: string
- baseBranch?: string
- setupDecision?: 'inherit' | 'run' | 'skip'
- }): Promise => ipcRenderer.invoke('worktrees:create', args),
+ create: (args: CreateWorktreeArgs): Promise =>
+ ipcRenderer.invoke('worktrees:create', args),
resolvePrBase: (args: {
repoId: string
@@ -1107,6 +1126,11 @@ const api = {
connectionId?: string
}): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> =>
ipcRenderer.invoke('fs:readFile', args),
+ listMarkdownDocuments: (args: {
+ rootPath: string
+ connectionId?: string
+ }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> =>
+ ipcRenderer.invoke('fs:listMarkdownDocuments', args),
writeFile: (args: {
filePath: string
content: string
diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts
index ae9c2d2a042..72e26e1ec11 100644
--- a/src/relay/pty-shell-utils.ts
+++ b/src/relay/pty-shell-utils.ts
@@ -42,10 +42,18 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi
// lsof returns ALL open files (sockets, log files, TTYs) and the first `n`-line
// could be any of them — not the actual working directory.
try {
- const { stdout: output } = await execFile('lsof', ['-p', String(pid), '-d', 'cwd', '-Fn'], {
- encoding: 'utf-8',
- timeout: 3000
- })
+ // Why: `-a` ANDs the -p and -d filters. Without it, macOS lsof ORs them
+ // and emits cwd records for every process on the system, so the n-line
+ // scan below picks up the first unrelated process (often pid ~391 with
+ // cwd `/`) and returns `/` regardless of the target pid's real cwd.
+ const { stdout: output } = await execFile(
+ 'lsof',
+ ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'],
+ {
+ encoding: 'utf-8',
+ timeout: 3000
+ }
+ )
const lines = output.split('\n')
for (const line of lines) {
if (line.startsWith('n') && line.includes('/')) {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 7837db669f8..aa6923d9b67 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -2,7 +2,7 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
-import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
+import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight, Search } from 'lucide-react'
import {
FOCUS_TERMINAL_PANE_EVENT,
SYNC_FIT_PANES_EVENT,
@@ -870,6 +870,27 @@ function App(): React.JSX.Element {
) : null}
+ {/* Why: always rendered (not gated on showSidebar) because the
+ worktree palette is global — it mirrors the ⌘J/Ctrl+Shift+J
+ shortcut which is live from every view including Settings and
+ Landing. The `sidebar-toggle` class is the shared titlebar
+ icon-button style used by back/forward/sidebar-toggle/right-
+ sidebar neighbors; the name is historical and applies to any
+ titlebar icon button, not only sidebar toggles. */}
+
+
+
+
+
+ {`Search (${isMac ? '⌘J' : 'Ctrl+Shift+J'})`}
+
+
{/* Why: Back/Forward traverse mixed worktree + Tasks history, so the
cluster is shown wherever the history shortcut is live (terminal or
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 69c2b8c4820..fe5ec79226b 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -897,6 +897,13 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warning, #f59e0b) 52%, transparent);
}
+.monaco-editor .monaco-markdown-doc-link {
+ color: #58a6ff !important;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
+ text-underline-offset: 2px;
+}
+
/* Why: Monaco's find-widget button tooltips (Close, Find in Selection, etc.)
render inside a `.context-view` wrapper → `.workbench-hover-container` →
`.workbench-hover.compact`, positioned above the target button. The wrapper's
diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css
index fb37e366e75..625696c41f1 100644
--- a/src/renderer/src/assets/markdown-preview.css
+++ b/src/renderer/src/assets/markdown-preview.css
@@ -142,6 +142,16 @@
text-decoration-color: currentColor;
}
+.markdown-body .markdown-doc-link {
+ text-decoration-style: solid;
+}
+
+.markdown-body .markdown-doc-link-broken {
+ color: var(--muted-foreground);
+ cursor: default !important;
+ text-decoration-style: dotted;
+}
+
.markdown-light .markdown-body a {
color: #0969da;
}
@@ -150,6 +160,11 @@
color: #58a6ff;
}
+.markdown-light .markdown-body .markdown-doc-link-broken,
+.markdown-dark .markdown-body .markdown-doc-link-broken {
+ color: var(--muted-foreground);
+}
+
.markdown-body code {
padding: 0.2em 0.4em;
border-radius: 5px;
diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css
index 59c0231bd14..25bcaacd260 100644
--- a/src/renderer/src/assets/rich-markdown-editor.css
+++ b/src/renderer/src/assets/rich-markdown-editor.css
@@ -592,6 +592,110 @@
color: var(--muted-foreground);
}
+.rich-markdown-doc-link-menu {
+ position: absolute;
+ z-index: 30;
+ display: flex;
+ width: min(360px, calc(100% - 24px));
+ max-height: 320px;
+ flex-direction: column;
+ overflow-y: auto;
+ border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--background) 92%, transparent);
+ box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2);
+ backdrop-filter: blur(16px);
+}
+
+.rich-markdown-doc-link-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 12px;
+ border: none;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+}
+
+.rich-markdown-doc-link-item:hover,
+.rich-markdown-doc-link-item.is-active {
+ background: var(--accent);
+}
+
+.rich-markdown-doc-link-item.is-empty {
+ color: var(--muted-foreground);
+ font-size: 13px;
+ font-style: italic;
+ cursor: default;
+}
+
+.rich-markdown-doc-link-item.is-empty:hover {
+ background: transparent;
+}
+
+.rich-markdown-doc-link-footer {
+ padding: 6px 12px;
+ border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
+ color: var(--muted-foreground);
+ font-size: 11px;
+}
+
+.rich-markdown-doc-link-hint {
+ padding: 6px 12px;
+ border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
+ color: var(--muted-foreground);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── Doc Links ───────────────────────────────────────── */
+
+.rich-markdown-editor .rich-markdown-doc-link {
+ color: #0969da;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
+ text-underline-offset: 2px;
+}
+
+.dark .rich-markdown-editor .rich-markdown-doc-link {
+ color: #58a6ff;
+}
+
+.rich-markdown-editor .rich-markdown-doc-link.rich-markdown-doc-link--missing {
+ color: var(--muted-foreground);
+ text-decoration-style: dashed;
+}
+
+.rich-markdown-mod-held .rich-markdown-editor .rich-markdown-doc-link {
+ cursor: pointer;
+}
+
+.rich-markdown-mod-held
+ .rich-markdown-editor
+ .rich-markdown-doc-link.rich-markdown-doc-link--missing {
+ cursor: default;
+}
+
+/* In-progress [[target]] text while cursor is inside. Matches the committed
+ doc-link styling so the visual doesn't jump when the atom node takes over. */
+.rich-markdown-editor .rich-markdown-doc-link-preview {
+ color: #0969da;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
+ text-underline-offset: 2px;
+}
+
+.dark .rich-markdown-editor .rich-markdown-doc-link-preview {
+ color: #58a6ff;
+}
+
+.rich-markdown-editor .rich-markdown-doc-link-preview.rich-markdown-doc-link-preview--missing {
+ color: var(--muted-foreground);
+ text-decoration-style: dashed;
+}
+
/* ── Link Bubble ──────────────────────────────────────── */
.rich-markdown-link-bubble {
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
index 3b27aab1996..c72a55ebde8 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
@@ -18,7 +18,8 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
-import type { TuiAgent } from '../../../shared/types'
+import type { SparsePreset, TuiAgent } from '../../../shared/types'
+import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect'
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
@@ -51,6 +52,10 @@ type NewWorkspaceComposerCardProps = {
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: string | null
+ canUseSparseCheckout: boolean
+ sparsePresets: SparsePreset[]
+ sparseSelectedPresetId: string | null
+ onSparseSelectPreset: (preset: SparsePreset | null) => void
}
function SetupCommandPreview({
@@ -186,7 +191,11 @@ export default function NewWorkspaceComposerCard({
onSetupDecisionChange,
shouldWaitForSetupCheck,
resolvedSetupDecision,
- createError
+ createError,
+ canUseSparseCheckout,
+ sparsePresets,
+ sparseSelectedPresetId,
+ onSparseSelectPreset
}: NewWorkspaceComposerCardProps): React.JSX.Element {
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
const openModal = useAppStore((s) => s.openModal)
@@ -337,8 +346,8 @@ export default function NewWorkspaceComposerCard({
@@ -347,7 +356,14 @@ export default function NewWorkspaceComposerCard({
textarea's 3px outset focus ring has horizontal breathing room
inside the overflow-hidden drawer above. Without it the ring
gets clipped on the right edge when the field is focused. */}
-
@@ -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}
/>
+ {sortedPresets.map((preset) => {
+ // Why: users can already have locally persisted presets from older
+ // builds or hand-edited state; a bad timestamp must not blank Settings.
+ const updatedLabel = formatSparsePresetUpdatedAt(preset.updatedAt)
+
+ return (
+
+ )}
+
+
+ )
+}
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. */}
diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts
index bbe438fb8e5..3f38a873122 100644
--- a/src/renderer/src/hooks/useComposerState.ts
+++ b/src/renderer/src/hooks/useComposerState.ts
@@ -16,6 +16,7 @@ import type {
OrcaHooks,
SetupDecision,
SetupRunPolicy,
+ SparsePreset,
TuiAgent
} from '../../../shared/types'
import {
@@ -35,6 +36,7 @@ import {
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
+import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
export type UseComposerStateOptions = {
initialRepoId?: string
@@ -119,6 +121,13 @@ export type ComposerCardProps = {
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: string | null
+ canUseSparseCheckout: boolean
+ /** Saved presets for the currently-selected repo. Empty array when no
+ * presets exist or when the repo is remote. */
+ sparsePresets: SparsePreset[]
+ /** ID of the selected sparse preset. Null means sparse checkout is off. */
+ sparseSelectedPresetId: string | null
+ onSparseSelectPreset: (preset: SparsePreset | null) => void
}
export type UseComposerStateResult = {
@@ -142,6 +151,7 @@ export type UseComposerStateResult = {
// modal wins when both are present, and the page takes over once the modal
// closes.
const composerDropStack: symbol[] = []
+const EMPTY_SPARSE_PRESETS: SparsePreset[] = []
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
const {
@@ -172,7 +182,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
closeModal: s.closeModal,
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget,
- prefetchWorkItems: s.prefetchWorkItems
+ prefetchWorkItems: s.prefetchWorkItems,
+ fetchSparsePresets: s.fetchSparsePresets
}))
)
const {
@@ -186,7 +197,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
closeModal,
openSettingsPage,
openSettingsTarget,
- prefetchWorkItems
+ prefetchWorkItems,
+ fetchSparsePresets
} = actions
const repos = useAppStore((s) => s.repos)
@@ -194,7 +206,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const settings = useAppStore((s) => s.settings)
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
-
+ const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
@@ -298,6 +310,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [advancedOpen, setAdvancedOpen] = useState(
persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false
)
+ const [sparseEnabled, setSparseEnabled] = useState(false)
+ const [sparseDirectories, setSparseDirectories] = useState('')
+ const [sparseSelectedPresetId, setSparseSelectedPresetId] = useState(null)
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
const [linkQuery, setLinkQuery] = useState('')
@@ -331,6 +346,46 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agentPromptRef.current = agentPrompt
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
+ const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
+ const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
+ const normalizedSparseDirectories = useMemo(
+ () => normalizeSparseDirectoryLines(sparseDirectories),
+ [sparseDirectories]
+ )
+ // Why: a preset attribution should only ride along if what's about to be
+ // created actually equals the saved preset. If the user picked a preset and
+ // then edited the textarea, we want the worktree to be a "Custom" sparse
+ // checkout — not falsely tagged as the original preset.
+ const effectivePresetId = useMemo(() => {
+ if (!sparseSelectedPresetId) {
+ return null
+ }
+ const selected = sparsePresets.find((preset) => preset.id === sparseSelectedPresetId)
+ if (!selected) {
+ return null
+ }
+ return sparseDirectoriesMatch(selected.directories, normalizedSparseDirectories)
+ ? selected.id
+ : null
+ }, [normalizedSparseDirectories, sparsePresets, sparseSelectedPresetId])
+
+ const sparseError = useMemo(() => {
+ if (!sparseEnabled) {
+ return null
+ }
+ if (selectedRepo?.connectionId) {
+ return 'Sparse checkout is only supported for local repos right now.'
+ }
+ if (normalizedSparseDirectories.length === 0) {
+ return 'Enter at least one repo-relative directory.'
+ }
+ if (
+ normalizedSparseDirectories.some((entry) => entry === '.' || entry.split('/').includes('..'))
+ ) {
+ return 'Use repo-relative directories, not root or parent paths.'
+ }
+ return null
+ }, [normalizedSparseDirectories, selectedRepo?.connectionId, sparseEnabled])
const parsedLinkedIssueNumber = useMemo(
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
[linkedIssue]
@@ -483,6 +538,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [eligibleRepos, repoId, setRepoId])
+ // Why: the compact sparse dropdown is always visible under Advanced, so
+ // presets must load before sparse mode is enabled.
+ useEffect(() => {
+ if (!repoId || selectedRepo?.connectionId) {
+ return
+ }
+ if (sparsePresetsByRepo[repoId] !== undefined) {
+ return
+ }
+ void fetchSparsePresets(repoId)
+ }, [fetchSparsePresets, repoId, selectedRepo?.connectionId, sparsePresetsByRepo])
+
// Why: detect agents for the selected repo. For local repos this runs once
// on mount (deduped by the store). For remote repos it re-runs when the
// selected repo changes so the agent list matches the SSH host.
@@ -920,6 +987,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem(null)
+ setSparseEnabled(false)
+ setSparseDirectories('')
+ // Why: presets are repo-scoped, so a stale selection from the prior
+ // repo would be meaningless after a repo switch.
+ setSparseSelectedPresetId(null)
// Why: the Start-from picker is repo-scoped, so any prior branch/PR
// selection is meaningless in the new repo. Resetting to undefined
// makes the field fall back to the new repo's effective base ref.
@@ -929,6 +1001,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[baseBranch, linkedWorkItem, repoId, setRepoId]
)
+ const handleSparseSelectPreset = useCallback((preset: SparsePreset | null): void => {
+ if (preset) {
+ setSparseEnabled(true)
+ setSparseDirectories(preset.directories.join('\n'))
+ setSparseSelectedPresetId(preset.id)
+ } else {
+ setSparseEnabled(false)
+ setSparseDirectories('')
+ setSparseSelectedPresetId(null)
+ }
+ }, [])
+
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
setBaseBranch(next)
setStartFromResetHint(null)
@@ -993,7 +1077,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!selectedRepo ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
- (requiresExplicitSetupChoice && !setupDecision)
+ (requiresExplicitSetupChoice && !setupDecision) ||
+ sparseError !== null
) {
return
}
@@ -1015,7 +1100,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
- const result = await createWorktree(repoId, workspaceName, baseBranch, effectiveSetupDecision)
+ const result = await createWorktree(
+ repoId,
+ workspaceName,
+ baseBranch,
+ effectiveSetupDecision,
+ sparseEnabled
+ ? {
+ directories: normalizedSparseDirectories,
+ ...(effectivePresetId ? { presetId: effectivePresetId } : {})
+ }
+ : undefined
+ )
const worktree = result.worktree
await applyWorktreeMeta(worktree.id, {
@@ -1075,6 +1171,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
issueCommandTemplate,
linkedPR,
linkedWorkItem?.url,
+ normalizedSparseDirectories,
note,
onCreated,
parsedLinkedIssueNumber,
@@ -1089,6 +1186,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setRightSidebarTab,
setSidebarOpen,
setupDecision,
+ sparseEnabled,
+ sparseError,
+ effectivePresetId,
tuiAgent,
shouldRunIssueAutomation,
shouldWaitForIssueAutomationCheck,
@@ -1111,7 +1211,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!workspaceName ||
!selectedRepo ||
shouldWaitForSetupCheck ||
- (requiresExplicitSetupChoice && !setupDecision)
+ (requiresExplicitSetupChoice && !setupDecision) ||
+ sparseError !== null
) {
return
}
@@ -1129,7 +1230,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
repoId,
workspaceName,
baseBranch,
- effectiveSetupDecision
+ effectiveSetupDecision,
+ sparseEnabled
+ ? {
+ directories: normalizedSparseDirectories,
+ ...(effectivePresetId ? { presetId: effectivePresetId } : {})
+ }
+ : undefined
)
const worktree = result.worktree
@@ -1181,6 +1288,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
createWorktree,
fallbackCreatureName,
name,
+ normalizedSparseDirectories,
note,
onCreated,
persistDraft,
@@ -1194,6 +1302,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setRightSidebarTab,
setSidebarOpen,
setupDecision,
+ sparseEnabled,
+ sparseError,
+ effectivePresetId,
shouldWaitForSetupCheck
]
)
@@ -1204,7 +1315,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
creating ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
- (requiresExplicitSetupChoice && !setupDecision)
+ (requiresExplicitSetupChoice && !setupDecision) ||
+ sparseError !== null
const cardProps: ComposerCardProps = {
eligibleRepos,
@@ -1258,7 +1370,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onSetupDecisionChange: setSetupDecision,
shouldWaitForSetupCheck,
resolvedSetupDecision,
- createError
+ createError,
+ canUseSparseCheckout: !selectedRepo?.connectionId,
+ sparsePresets,
+ sparseSelectedPresetId,
+ onSparseSelectPreset: handleSparseSelectPreset
}
return {
diff --git a/src/renderer/src/lib/sparse-paths.ts b/src/renderer/src/lib/sparse-paths.ts
new file mode 100644
index 00000000000..38e9738c7a1
--- /dev/null
+++ b/src/renderer/src/lib/sparse-paths.ts
@@ -0,0 +1,41 @@
+const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
+
+export function isAbsoluteSparseDirectoryPath(value: string): boolean {
+ const entry = value.trim()
+ return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
+}
+
+/** Normalize the user's free-form textarea input into a clean directory list:
+ * trim whitespace, convert backslashes to forward slashes, strip leading and
+ * trailing slashes, drop empty lines, dedupe. Order preserved so the
+ * textarea round-trips with the user's typing intent intact. */
+export function normalizeSparseDirectoryLines(value: string): string[] {
+ const seen = new Set()
+ return value
+ .split('\n')
+ .map((entry) =>
+ entry
+ .trim()
+ .replace(/\\/g, '/')
+ .replace(/^\/+|\/+$/g, '')
+ )
+ .filter((entry) => entry.length > 0)
+ .filter((entry) => {
+ if (seen.has(entry)) {
+ return false
+ }
+ seen.add(entry)
+ return true
+ })
+}
+
+/** Order-independent set comparison so "shared/ui\npackages/web" matches
+ * "packages/web\nshared/ui" — used by the composer to decide whether the
+ * current textarea content matches a saved preset. */
+export function sparseDirectoriesMatch(left: string[], right: string[]): boolean {
+ if (left.length !== right.length) {
+ return false
+ }
+ const set = new Set(left)
+ return right.every((entry) => set.has(entry))
+}
diff --git a/src/renderer/src/lib/sparse-preset-draft.test.ts b/src/renderer/src/lib/sparse-preset-draft.test.ts
new file mode 100644
index 00000000000..852dd874f72
--- /dev/null
+++ b/src/renderer/src/lib/sparse-preset-draft.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import { parseSparsePresetDirectories } from './sparse-preset-draft'
+
+describe('parseSparsePresetDirectories', () => {
+ it('normalizes textarea input into unique repo-relative directories', () => {
+ expect(
+ parseSparsePresetDirectories(`
+ src\\renderer
+ packages/ui/
+ src/renderer
+ `)
+ ).toEqual({
+ directories: ['src/renderer', 'packages/ui'],
+ error: null
+ })
+ })
+
+ it('requires at least one directory', () => {
+ expect(parseSparsePresetDirectories(' \n ')).toEqual({
+ directories: [],
+ error: 'Add at least one directory.'
+ })
+ })
+
+ it('rejects root and parent path entries', () => {
+ expect(parseSparsePresetDirectories('.')).toEqual({
+ directories: [],
+ error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
+ })
+ expect(parseSparsePresetDirectories('src/../packages')).toEqual({
+ directories: [],
+ error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
+ })
+ expect(parseSparsePresetDirectories('/')).toEqual({
+ directories: [],
+ error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
+ })
+ })
+
+ it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
+ 'rejects absolute directory input before normalization: %s',
+ (entry) => {
+ expect(parseSparsePresetDirectories(entry)).toEqual({
+ directories: [],
+ error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
+ })
+ }
+ )
+})
diff --git a/src/renderer/src/lib/sparse-preset-draft.ts b/src/renderer/src/lib/sparse-preset-draft.ts
new file mode 100644
index 00000000000..ace8052f3b8
--- /dev/null
+++ b/src/renderer/src/lib/sparse-preset-draft.ts
@@ -0,0 +1,42 @@
+import { isAbsoluteSparseDirectoryPath, normalizeSparseDirectoryLines } from '@/lib/sparse-paths'
+
+export type SparsePresetDirectoryParseResult = {
+ directories: string[]
+ error: string | null
+}
+
+export function parseSparsePresetDirectories(value: string): SparsePresetDirectoryParseResult {
+ const rawEntries = value
+ .split('\n')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+
+ // Why: absolute paths can look repo-relative after slash normalization.
+ if (rawEntries.some(isAbsoluteSparseDirectoryPath)) {
+ return {
+ directories: [],
+ error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
+ }
+ }
+
+ const directories = normalizeSparseDirectoryLines(value)
+
+ if (directories.length === 0) {
+ return {
+ directories,
+ error: 'Add at least one directory.'
+ }
+ }
+
+ if (directories.some((entry) => entry === '.' || entry.split('/').includes('..'))) {
+ return {
+ directories: [],
+ error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
+ }
+ }
+
+ return {
+ directories,
+ error: null
+ }
+}
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/index.ts b/src/renderer/src/store/index.ts
index aeac90d9449..2f4698c2b8d 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { AppState } from './types'
import { createRepoSlice } from './slices/repos'
+import { createSparsePresetsSlice } from './slices/sparse-presets'
import { createWorktreeSlice } from './slices/worktrees'
import { createTerminalSlice } from './slices/terminals'
import { createTabsSlice } from './slices/tabs'
@@ -25,6 +26,7 @@ import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
export const useAppStore = create()((...a) => ({
...createRepoSlice(...a),
+ ...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
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/sparse-presets.test.ts b/src/renderer/src/store/slices/sparse-presets.test.ts
new file mode 100644
index 00000000000..ee2df9689c2
--- /dev/null
+++ b/src/renderer/src/store/slices/sparse-presets.test.ts
@@ -0,0 +1,233 @@
+import { create } from 'zustand'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { SparsePreset } from '../../../../shared/types'
+import type { AppState } from '../types'
+import { createSparsePresetsSlice } from './sparse-presets'
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn()
+ }
+}))
+
+const mockApi = {
+ sparsePresets: {
+ list: vi.fn(),
+ save: vi.fn(),
+ remove: vi.fn()
+ }
+}
+
+// @ts-expect-error -- test shim
+globalThis.window = { api: mockApi }
+
+function createTestStore() {
+ return create()((...a) => ({ ...createSparsePresetsSlice(...a) }) as AppState)
+}
+
+function makePreset(
+ overrides: Partial & { id: string; repoId: string }
+): SparsePreset {
+ return {
+ name: overrides.id,
+ directories: ['packages/app'],
+ createdAt: 1,
+ updatedAt: 1,
+ ...overrides
+ }
+}
+
+describe('createSparsePresetsSlice', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockApi.sparsePresets.list.mockResolvedValue([])
+ mockApi.sparsePresets.save.mockImplementation((args: Partial) =>
+ Promise.resolve(
+ makePreset({
+ id: args.id ?? `preset-${args.name}`,
+ repoId: args.repoId ?? 'repo-1',
+ name: args.name ?? 'Preset',
+ directories: args.directories ?? ['packages/app'],
+ updatedAt: 2
+ })
+ )
+ )
+ mockApi.sparsePresets.remove.mockResolvedValue(undefined)
+ })
+
+ it('fetches presets into the requested repo bucket', async () => {
+ const store = createTestStore()
+ const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
+ mockApi.sparsePresets.list.mockResolvedValueOnce([preset])
+
+ await store.getState().fetchSparsePresets('repo-1')
+
+ expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
+ expect(store.getState().sparsePresetsByRepo).toEqual({ 'repo-1': [preset] })
+ expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
+ expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
+ })
+
+ it('keeps an unfetched repo bucket missing while presets are loading', async () => {
+ const store = createTestStore()
+ const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
+ let resolveList: (presets: SparsePreset[]) => void = () => {}
+ mockApi.sparsePresets.list.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveList = resolve
+ })
+ )
+
+ const fetchPromise = store.getState().fetchSparsePresets('repo-1')
+
+ expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
+ expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(true)
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loading')
+
+ resolveList([preset])
+ await fetchPromise
+
+ expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
+ expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
+ })
+
+ it('does not refetch while a repo bucket is loading or already loaded', async () => {
+ const store = createTestStore()
+ let resolveList: (presets: SparsePreset[]) => void = () => {}
+ mockApi.sparsePresets.list.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveList = resolve
+ })
+ )
+
+ const fetchPromise = store.getState().fetchSparsePresets('repo-1')
+ await store.getState().fetchSparsePresets('repo-1')
+
+ expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
+
+ resolveList([])
+ await fetchPromise
+ await store.getState().fetchSparsePresets('repo-1')
+
+ expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
+ })
+
+ it('clears loading state without marking the repo loaded when fetch fails', async () => {
+ const store = createTestStore()
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
+
+ try {
+ await store.getState().fetchSparsePresets('repo-1')
+
+ expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
+ expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
+ expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBe('disk failed')
+ } finally {
+ consoleError.mockRestore()
+ }
+ })
+
+ it('clears a failed fetch status when retrying presets succeeds', async () => {
+ const store = createTestStore()
+ const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockApi.sparsePresets.list
+ .mockRejectedValueOnce(new Error('disk failed'))
+ .mockResolvedValueOnce([preset])
+
+ try {
+ await store.getState().fetchSparsePresets('repo-1')
+ await store.getState().fetchSparsePresets('repo-1')
+
+ expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(2)
+ expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
+ expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
+ } finally {
+ consoleError.mockRestore()
+ }
+ })
+
+ it('saves presets per repo and sorts the repo list by name', async () => {
+ const store = createTestStore()
+ store.setState({
+ sparsePresetsByRepo: {
+ 'repo-1': [makePreset({ id: 'z', repoId: 'repo-1', name: 'Zed' })],
+ 'repo-2': [makePreset({ id: 'other', repoId: 'repo-2', name: 'Other' })]
+ }
+ } as Partial)
+
+ const saved = await store.getState().saveSparsePreset({
+ repoId: 'repo-1',
+ name: 'Api',
+ directories: ['packages/api']
+ })
+
+ expect(saved?.name).toBe('Api')
+ expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
+ 'Api',
+ 'Zed'
+ ])
+ expect(store.getState().sparsePresetsByRepo['repo-2'].map((preset) => preset.name)).toEqual([
+ 'Other'
+ ])
+ })
+
+ it('loads an unfetched repo bucket before saving so existing presets stay visible', async () => {
+ const store = createTestStore()
+ const existing = makePreset({ id: 'existing', repoId: 'repo-1', name: 'Existing' })
+ mockApi.sparsePresets.list.mockResolvedValueOnce([existing])
+
+ const saved = await store.getState().saveSparsePreset({
+ repoId: 'repo-1',
+ name: 'Api',
+ directories: ['packages/api']
+ })
+
+ expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
+ expect(mockApi.sparsePresets.save).toHaveBeenCalledTimes(1)
+ expect(saved?.name).toBe('Api')
+ expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
+ 'Api',
+ 'Existing'
+ ])
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
+ })
+
+ it('does not save or synthesize a repo bucket when presets fail to load first', async () => {
+ const store = createTestStore()
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
+
+ try {
+ const saved = await store.getState().saveSparsePreset({
+ repoId: 'repo-1',
+ name: 'Api',
+ directories: ['packages/api']
+ })
+
+ expect(saved).toBeNull()
+ expect(mockApi.sparsePresets.save).not.toHaveBeenCalled()
+ expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
+ expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
+ } finally {
+ consoleError.mockRestore()
+ }
+ })
+
+ it('restores the previous repo presets when remove fails', async () => {
+ const store = createTestStore()
+ const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
+ mockApi.sparsePresets.remove.mockRejectedValueOnce(new Error('disk failed'))
+ store.setState({ sparsePresetsByRepo: { 'repo-1': [preset] } } as Partial)
+
+ await store.getState().removeSparsePreset({ repoId: 'repo-1', presetId: 'preset-1' })
+
+ expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
+ })
+})
diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts
new file mode 100644
index 00000000000..4e103d9aa5b
--- /dev/null
+++ b/src/renderer/src/store/slices/sparse-presets.ts
@@ -0,0 +1,135 @@
+import type { StateCreator } from 'zustand'
+import { toast } from 'sonner'
+import type { AppState } from '../types'
+import type { SparsePreset } from '../../../../shared/types'
+
+const ERROR_TOAST_DURATION = 60_000
+
+export type SparsePresetsLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'
+
+export type SparsePresetsSlice = {
+ /** Per-repo preset list. Lazily populated by `fetchSparsePresets`; missing
+ * key means "not yet fetched", empty array means "fetched, none exist". */
+ sparsePresetsByRepo: Record
+ /** Per-repo fetch guard so missing preset buckets keep their loading meaning. */
+ sparsePresetsLoadingByRepo: Record
+ sparsePresetsLoadStatusByRepo: Record
+ sparsePresetsErrorByRepo: Record
+ fetchSparsePresets: (repoId: string) => Promise
+ saveSparsePreset: (args: {
+ repoId: string
+ id?: string
+ name: string
+ directories: string[]
+ }) => Promise
+ removeSparsePreset: (args: { repoId: string; presetId: string }) => Promise
+}
+
+export const createSparsePresetsSlice: StateCreator = (
+ set,
+ get
+) => ({
+ sparsePresetsByRepo: {},
+ sparsePresetsLoadingByRepo: {},
+ sparsePresetsLoadStatusByRepo: {},
+ sparsePresetsErrorByRepo: {},
+
+ fetchSparsePresets: async (repoId) => {
+ const state = get()
+ if (
+ state.sparsePresetsByRepo[repoId] !== undefined ||
+ state.sparsePresetsLoadingByRepo[repoId]
+ ) {
+ return
+ }
+ set((s) => ({
+ sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: true },
+ sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loading' },
+ sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
+ }))
+ try {
+ const presets = await window.api.sparsePresets.list({ repoId })
+ set((s) => ({
+ sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: presets },
+ sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
+ sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loaded' },
+ sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
+ }))
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ set((s) => ({
+ sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
+ sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'error' },
+ sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: message }
+ }))
+ console.error(`Failed to fetch sparse presets for repo ${repoId}:`, err)
+ }
+ },
+
+ saveSparsePreset: async (args) => {
+ try {
+ if (get().sparsePresetsByRepo[args.repoId] === undefined) {
+ // Why: a saved preset alone is not an authoritative repo bucket; load
+ // existing presets first so we do not hide them behind a one-item cache.
+ await get().fetchSparsePresets(args.repoId)
+ if (get().sparsePresetsByRepo[args.repoId] === undefined) {
+ toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
+ description: 'Presets must load before saving.',
+ duration: ERROR_TOAST_DURATION
+ })
+ return null
+ }
+ }
+ const saved = await window.api.sparsePresets.save(args)
+ set((s) => {
+ const existing = s.sparsePresetsByRepo[args.repoId]
+ if (existing === undefined) {
+ return {}
+ }
+ const without = existing.filter((preset) => preset.id !== saved.id)
+ return {
+ sparsePresetsByRepo: {
+ ...s.sparsePresetsByRepo,
+ [args.repoId]: [...without, saved].sort((left, right) =>
+ left.name.localeCompare(right.name)
+ )
+ }
+ }
+ })
+ toast.success(args.id ? 'Preset updated' : 'Preset saved', { description: saved.name })
+ return saved
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
+ description: message,
+ duration: ERROR_TOAST_DURATION
+ })
+ return null
+ }
+ },
+
+ removeSparsePreset: async ({ repoId, presetId }) => {
+ const previous = get().sparsePresetsByRepo[repoId] ?? []
+ // Why: optimistic local update keeps the popover responsive — toast handles
+ // the failure path by restoring state.
+ set((s) => ({
+ sparsePresetsByRepo: {
+ ...s.sparsePresetsByRepo,
+ [repoId]: previous.filter((preset) => preset.id !== presetId)
+ }
+ }))
+ try {
+ await window.api.sparsePresets.remove({ repoId, presetId })
+ toast.success('Preset removed')
+ } catch (err) {
+ set((s) => ({
+ sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: previous }
+ }))
+ const message = err instanceof Error ? err.message : String(err)
+ toast.error('Failed to remove preset', {
+ description: message,
+ duration: ERROR_TOAST_DURATION
+ })
+ }
+ }
+})
diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts
index 530b3d38af4..e5301fd632c 100644
--- a/src/renderer/src/store/slices/store-session-cascades.test.ts
+++ b/src/renderer/src/store/slices/store-session-cascades.test.ts
@@ -90,6 +90,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
+import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@@ -113,6 +114,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history'
function createTestStore() {
return create()((...a) => ({
...createRepoSlice(...a),
+ ...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts
index 3ebe35a7bf5..5c3bb59022e 100644
--- a/src/renderer/src/store/slices/store-test-helpers.ts
+++ b/src/renderer/src/store/slices/store-test-helpers.ts
@@ -9,6 +9,7 @@ import type {
} from '../../../../shared/types'
import type { OpenFile } from './editor'
import { createRepoSlice } from './repos'
+import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@@ -40,6 +41,7 @@ export const TEST_REPO = {
export function createTestStore() {
return create()((...a) => ({
...createRepoSlice(...a),
+ ...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts
index 44ba32a2307..e979ce342f2 100644
--- a/src/renderer/src/store/slices/tabs.test.ts
+++ b/src/renderer/src/store/slices/tabs.test.ts
@@ -85,6 +85,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
+import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@@ -110,6 +111,7 @@ const WT = 'repo1::/tmp/feature'
function createTestStore() {
return create()((...a) => ({
...createRepoSlice(...a),
+ ...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 6979da94ca2..e81e9790f45 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -177,8 +177,6 @@ export type UISlice = {
) => void
markOrcaHookRepoAlwaysTrusted: (repoId: string) => void
clearOrcaHookTrustForRepo: (repoId: string) => void
- searchQuery: string
- setSearchQuery: (q: string) => void
groupBy: 'none' | 'repo' | 'pr-status'
setGroupBy: (g: UISlice['groupBy']) => void
sortBy: 'name' | 'smart' | 'recent' | 'repo'
@@ -435,9 +433,6 @@ export const createUISlice: StateCreator = (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
diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts
index 08020065c58..7449ff88ba7 100644
--- a/src/renderer/src/store/slices/worktree-helpers.ts
+++ b/src/renderer/src/store/slices/worktree-helpers.ts
@@ -1,5 +1,6 @@
import type {
CreateWorktreeResult,
+ CreateSparseCheckoutRequest,
SetupDecision,
Worktree,
WorktreeMeta
@@ -40,7 +41,8 @@ export type WorktreeSlice = {
repoId: string,
name: string,
baseBranch?: string,
- setupDecision?: SetupDecision
+ setupDecision?: SetupDecision,
+ sparseCheckout?: CreateSparseCheckoutRequest
) => Promise
removeWorktree: (
worktreeId: string,
diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts
index 8a47ddaa39a..364eede3297 100644
--- a/src/renderer/src/store/slices/worktrees.ts
+++ b/src/renderer/src/store/slices/worktrees.ts
@@ -11,6 +11,16 @@ import {
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
+function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
+ if (a === b) {
+ return true
+ }
+ if (!a || !b || a.length !== b.length) {
+ return !a?.length && !b?.length
+ }
+ return a.every((v, i) => v === b[i])
+}
+
function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean {
if (!current || current.length !== next.length) {
return false
@@ -26,6 +36,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.branch === candidate.branch &&
worktree.isBare === candidate.isBare &&
worktree.isMainWorktree === candidate.isMainWorktree &&
+ worktree.isSparse === candidate.isSparse &&
worktree.displayName === candidate.displayName &&
worktree.comment === candidate.comment &&
worktree.linkedIssue === candidate.linkedIssue &&
@@ -34,7 +45,9 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.isUnread === candidate.isUnread &&
worktree.isPinned === candidate.isPinned &&
worktree.sortOrder === candidate.sortOrder &&
- worktree.lastActivityAt === candidate.lastActivityAt
+ worktree.lastActivityAt === candidate.lastActivityAt &&
+ worktree.sparseBaseRef === candidate.sparseBaseRef &&
+ arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories)
)
})
}
@@ -86,7 +99,7 @@ export const createWorktreeSlice: StateCreator
await Promise.all(repos.map((r) => get().fetchWorktrees(r.id)))
},
- createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit') => {
+ createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit', sparseCheckout) => {
const retryableConflictPatterns = [
/already exists locally/i,
/already exists on a remote/i,
@@ -103,7 +116,8 @@ export const createWorktreeSlice: StateCreator
repoId,
name: candidateName,
baseBranch,
- setupDecision
+ setupDecision,
+ sparseCheckout
})
// Why: a file watcher (worktrees.onChanged) can fire between the
// backend creating the worktree and this callback running, causing
diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts
index 85b5d48c59f..23a9af342a4 100644
--- a/src/renderer/src/store/types.ts
+++ b/src/renderer/src/store/types.ts
@@ -1,4 +1,5 @@
import type { RepoSlice } from './slices/repos'
+import type { SparsePresetsSlice } from './slices/sparse-presets'
import type { WorktreeSlice } from './slices/worktrees'
import type { TerminalSlice } from './slices/terminals'
import type { TabsSlice } from './slices/tabs'
@@ -20,6 +21,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents'
import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
export type AppState = RepoSlice &
+ SparsePresetsSlice &
WorktreeSlice &
TerminalSlice &
TabsSlice &
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 71edb8184dd..b5eeee79d6e 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -214,6 +214,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
repos: [],
+ sparsePresetsByRepo: {},
worktreeMeta: {},
settings: getDefaultSettings(homedir),
ui: getDefaultUIState(),
diff --git a/src/shared/types.ts b/src/shared/types.ts
index bec8bcf6c04..25e1813d306 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -48,6 +48,7 @@ export type GitWorktreeInfo = {
head: string
branch: string
isBare: boolean
+ isSparse?: boolean
/** True for the repo's main working tree (the first entry from `git worktree list`).
* Linked worktrees created via `git worktree add` have this set to false. */
isMainWorktree: boolean
@@ -67,6 +68,11 @@ export type Worktree = {
isPinned: boolean
sortOrder: number
lastActivityAt: number
+ sparseDirectories?: string[]
+ sparseBaseRef?: string
+ /** ID of the saved preset this worktree was created from, if any. Cleared
+ * when the worktree is no longer sparse on refresh. */
+ sparsePresetId?: string
diffComments?: DiffComment[]
} & GitWorktreeInfo
@@ -82,6 +88,9 @@ export type WorktreeMeta = {
isPinned: boolean
sortOrder: number
lastActivityAt: number
+ sparseDirectories?: string[]
+ sparseBaseRef?: string
+ sparsePresetId?: string
diffComments?: DiffComment[]
}
@@ -690,11 +699,32 @@ export type WorktreeSetupLaunch = {
envVars: Record
}
+export type CreateSparseCheckoutRequest = {
+ directories: string[]
+ /** Set when the directories came from a saved preset and the user did not
+ * modify them — recorded on WorktreeMeta so the worktree can show "from
+ * preset X" later. Cleared if the user edited the textarea. */
+ presetId?: string
+}
+
+/** A reusable per-repo sparse directory list. Saved by the user from the
+ * composer; surfaced again the next time they create a worktree in the same
+ * repo. The MVP scope (no preset) is `presetId === undefined`. */
+export type SparsePreset = {
+ id: string
+ repoId: string
+ name: string
+ directories: string[]
+ createdAt: number
+ updatedAt: number
+}
+
export type CreateWorktreeArgs = {
repoId: string
name: string
baseBranch?: string
setupDecision?: SetupDecision
+ sparseCheckout?: CreateSparseCheckoutRequest
}
export type CreateWorktreeResult = {
@@ -1229,6 +1259,9 @@ export type PersistedTrustedOrcaHooks = Record
worktreeMeta: Record
settings: GlobalSettings
ui: PersistedUIState
@@ -1247,6 +1280,13 @@ export type DirEntry = {
isSymlink: boolean
}
+export type MarkdownDocument = {
+ filePath: string
+ relativePath: string
+ basename: string
+ name: string
+}
+
// ─── Filesystem watcher ─────────────────────────────────────
export type FsChangeEvent = {
kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow'