fix(worktree): restore sparse checkout without per-poll git subprocesses (#1316)
Co-authored-by: Orca <help@stably.ai> Co-authored-by: Colin <colin.chambachan@gmail.com>
This commit is contained in:
parent
d4bbf473c1
commit
bbd77c2449
|
|
@ -1,17 +1,38 @@
|
|||
/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner
|
||||
mock harness, and splitting them would duplicate setup without a clearer boundary. */
|
||||
import type * as FsPromises from 'fs/promises'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
gitExecFileSyncMock,
|
||||
translateWslOutputPathsMock,
|
||||
statMock,
|
||||
resolveGitDirMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileSyncMock: vi.fn()
|
||||
gitExecFileSyncMock: vi.fn(),
|
||||
translateWslOutputPathsMock: vi.fn((output: string) => output),
|
||||
statMock: vi.fn(),
|
||||
resolveGitDirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileSync: gitExecFileSyncMock,
|
||||
translateWslOutputPaths: (output: string) => output
|
||||
translateWslOutputPaths: translateWslOutputPathsMock
|
||||
}))
|
||||
|
||||
import { removeWorktree } from './worktree'
|
||||
vi.mock('./status', () => ({
|
||||
resolveGitDir: resolveGitDirMock
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
|
||||
return { ...actual, stat: statMock }
|
||||
})
|
||||
|
||||
import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree'
|
||||
|
||||
type MockResult = {
|
||||
error?: Error
|
||||
|
|
@ -54,11 +75,19 @@ describe('removeWorktree', () => {
|
|||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockReset()
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => output)
|
||||
statMock.mockReset()
|
||||
// Default: no worktree has a sparse-checkout config file. Tests that need
|
||||
// sparse detection override this.
|
||||
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
resolveGitDirMock.mockReset()
|
||||
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
|
||||
})
|
||||
|
||||
it('removes the worktree, prunes stale refs, and deletes its local branch', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -68,7 +97,7 @@ HEAD def456
|
|||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -92,7 +121,7 @@ branch refs/heads/main
|
|||
|
||||
it('skips branch deletion when another worktree still points at the branch', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -106,7 +135,7 @@ HEAD def456
|
|||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -125,7 +154,7 @@ branch refs/heads/feature/test
|
|||
expect.arrayContaining([
|
||||
'git worktree remove /repo-feature',
|
||||
'git worktree prune',
|
||||
'git worktree list --porcelain'
|
||||
'git worktree list --porcelain -z'
|
||||
])
|
||||
)
|
||||
expect(calls).not.toContain('git branch -D feature/test')
|
||||
|
|
@ -134,7 +163,7 @@ branch refs/heads/feature/test
|
|||
|
||||
it('deletes the branch after prune removes stale sibling worktree entries', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -149,7 +178,7 @@ branch refs/heads/feature/test
|
|||
prunable gitdir file points to non-existent location
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -172,7 +201,7 @@ branch refs/heads/main
|
|||
|
||||
it('passes --force before the worktree path when forced removal is requested', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -182,7 +211,7 @@ HEAD def456
|
|||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -197,7 +226,7 @@ branch refs/heads/main
|
|||
|
||||
it('matches Windows worktree paths before deleting the branch', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree C:/repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -207,7 +236,7 @@ HEAD def456
|
|||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree C:/repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -230,7 +259,7 @@ branch refs/heads/main
|
|||
it('keeps removal successful when branch cleanup fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -240,7 +269,7 @@ HEAD def456
|
|||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
|
@ -262,3 +291,181 @@ branch refs/heads/main
|
|||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listWorktrees', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockReset()
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => output)
|
||||
statMock.mockReset()
|
||||
// Default: no worktree has a sparse-checkout config file. Tests that need
|
||||
// sparse detection override this.
|
||||
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
resolveGitDirMock.mockReset()
|
||||
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
|
||||
})
|
||||
|
||||
it('translates parsed path fields from NUL-delimited porcelain output', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
|
||||
'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0'
|
||||
})
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => {
|
||||
expect(output).not.toContain('\0')
|
||||
return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
|
||||
})
|
||||
|
||||
await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([
|
||||
{
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature/test',
|
||||
isBare: false,
|
||||
isSparse: true,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
// Why: the non-sparse main worktree gets an fs probe of its sparse config
|
||||
// file; the linked worktree short-circuits on the parsed `sparse` token and
|
||||
// does not. Only one git subprocess runs regardless of worktree count.
|
||||
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
|
||||
expect(statMock).toHaveBeenCalledTimes(1)
|
||||
expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args.join(' ') === 'worktree list --porcelain -z') {
|
||||
return {
|
||||
stdout:
|
||||
'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' +
|
||||
'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0',
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected git call: ${args.join(' ')}`)
|
||||
})
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => {
|
||||
expect(output).not.toContain('\0')
|
||||
return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\')
|
||||
})
|
||||
const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature'
|
||||
resolveGitDirMock.mockImplementation(async (worktreePath: string) =>
|
||||
worktreePath === featureWorktreePath
|
||||
? `${featureWorktreePath}\\.git-worktrees\\feature`
|
||||
: `${worktreePath}/.git`
|
||||
)
|
||||
statMock.mockImplementation(async (filePath: string) => {
|
||||
if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) {
|
||||
return { isFile: () => true, size: 32 }
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')
|
||||
|
||||
expect(worktrees).toEqual([
|
||||
{
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature/test',
|
||||
isBare: false,
|
||||
isSparse: true,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath)
|
||||
// Why: the detection path must not spawn a git subprocess per worktree —
|
||||
// the perf regression in #1131 came from `git sparse-checkout list` firing
|
||||
// on every poll.
|
||||
expect(getGitCalls()).toEqual(['git worktree list --porcelain -z'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSparseWorktree', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockReset()
|
||||
translateWslOutputPathsMock.mockImplementation((output: string) => output)
|
||||
statMock.mockReset()
|
||||
// Default: no worktree has a sparse-checkout config file. Tests that need
|
||||
// sparse detection override this.
|
||||
statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
resolveGitDirMock.mockReset()
|
||||
resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`)
|
||||
})
|
||||
|
||||
it('separates sparse checkout directory operands from options', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src'])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['sparse-checkout', 'set', '--', '-docs', 'src'],
|
||||
{ cwd: '/repo-feature' }
|
||||
)
|
||||
})
|
||||
|
||||
it('removes the worktree and deletes the created branch when sparse setup fails', async () => {
|
||||
mockGitCommands({
|
||||
'git sparse-checkout set -- packages/web': {
|
||||
error: new Error('sparse setup failed')
|
||||
},
|
||||
'git worktree list --porcelain -z': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain -z#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
|
||||
).rejects.toThrow('sparse setup failed')
|
||||
|
||||
const calls = getGitCalls()
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'git worktree add --no-checkout -b feature/test /repo-feature',
|
||||
'git sparse-checkout init --cone',
|
||||
'git sparse-checkout set -- packages/web',
|
||||
'git worktree remove --force /repo-feature',
|
||||
'git worktree prune',
|
||||
'git branch -D feature/test'
|
||||
])
|
||||
)
|
||||
expectGitCallOrder(
|
||||
calls,
|
||||
'git sparse-checkout set -- packages/web',
|
||||
'git worktree remove --force /repo-feature'
|
||||
)
|
||||
expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise<Git
|
|||
return 'unknown'
|
||||
}
|
||||
|
||||
async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
const dotGitPath = path.join(worktreePath, '.git')
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { posix, win32 } from 'path'
|
||||
import { stat } from 'fs/promises'
|
||||
import { join, posix, win32 } from 'path'
|
||||
import type { GitWorktreeInfo } from '../../shared/types'
|
||||
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
|
||||
import { resolveGitDir } from './status'
|
||||
|
||||
type SparseWorktreeCreateError = Error & {
|
||||
cleanupFailed?: boolean
|
||||
}
|
||||
|
||||
function normalizeLocalBranchRef(branch: string): string {
|
||||
return branch.replace(/^refs\/heads\//, '')
|
||||
|
|
@ -29,21 +35,23 @@ function looksLikeWindowsPath(pathValue: string): boolean {
|
|||
*/
|
||||
export function parseWorktreeList(output: string): GitWorktreeInfo[] {
|
||||
const worktrees: GitWorktreeInfo[] = []
|
||||
// [Fix]: Use /\r?\n\r?\n/ to handle both LF and CRLF (\r\n) line endings,
|
||||
// which are common when running git on Windows.
|
||||
const blocks = output.trim().split(/\r?\n\r?\n/)
|
||||
const blocks = output.includes('\0')
|
||||
? parseNullDelimitedWorktreeBlocks(output)
|
||||
: output
|
||||
.trim()
|
||||
.split(/\r?\n\r?\n/)
|
||||
.map((block) => block.trim().split(/\r?\n/))
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) {
|
||||
for (const lines of blocks) {
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// [Fix]: Use /\r?\n/ to handle both LF and CRLF (\r\n) line endings.
|
||||
const lines = block.trim().split(/\r?\n/)
|
||||
let path = ''
|
||||
let head = ''
|
||||
let branch = ''
|
||||
let isBare = false
|
||||
let isSparse = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
|
|
@ -54,12 +62,21 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
|
|||
branch = line.slice('branch '.length)
|
||||
} else if (line === 'bare') {
|
||||
isBare = true
|
||||
} else if (line === 'sparse') {
|
||||
isSparse = true
|
||||
}
|
||||
}
|
||||
|
||||
if (path) {
|
||||
// `git worktree list` always emits the main working tree first.
|
||||
worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 })
|
||||
worktrees.push({
|
||||
path,
|
||||
head,
|
||||
branch,
|
||||
isBare,
|
||||
...(isSparse ? { isSparse } : {}),
|
||||
isMainWorktree: worktrees.length === 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,14 +88,24 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] {
|
|||
*/
|
||||
export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
// Why: when git runs inside WSL, worktree paths are Linux-native
|
||||
// (e.g. /home/user/repo). Translate them back to Windows UNC paths
|
||||
// so the rest of Orca can access them via Node fs APIs.
|
||||
const translated = translateWslOutputPaths(stdout, repoPath)
|
||||
return parseWorktreeList(translated)
|
||||
// Why: WSL path translation is line-oriented, but `-z` porcelain output is
|
||||
// NUL-delimited. Parse first so only complete path fields are translated.
|
||||
const worktrees = parseWorktreeList(stdout).map((worktree) => {
|
||||
const translatedPath = translateWorktreePath(worktree.path, repoPath)
|
||||
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
|
||||
})
|
||||
return Promise.all(
|
||||
worktrees.map(async (worktree) => {
|
||||
if (worktree.isBare || worktree.isSparse) {
|
||||
return worktree
|
||||
}
|
||||
const isSparse = await detectSparseCheckout(worktree.path)
|
||||
return isSparse ? { ...worktree, isSparse } : worktree
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
@ -96,7 +123,8 @@ export async function addWorktree(
|
|||
worktreePath: string,
|
||||
branch: string,
|
||||
baseBranch?: string,
|
||||
refreshLocalBaseRef = false
|
||||
refreshLocalBaseRef = false,
|
||||
noCheckout = false
|
||||
): Promise<void> {
|
||||
// Why: Some users want Orca-created worktrees to make plain commands like
|
||||
// `git diff main...HEAD` work out of the box, while others do not want
|
||||
|
|
@ -152,13 +180,49 @@ export async function addWorktree(
|
|||
}
|
||||
}
|
||||
|
||||
const args = ['worktree', 'add', '-b', branch, worktreePath]
|
||||
const args = ['worktree', 'add']
|
||||
if (noCheckout) {
|
||||
args.push('--no-checkout')
|
||||
}
|
||||
args.push('-b', branch, worktreePath)
|
||||
if (baseBranch) {
|
||||
args.push(baseBranch)
|
||||
}
|
||||
await gitExecFileAsync(args, { cwd: repoPath })
|
||||
}
|
||||
|
||||
export async function addSparseWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
directories: string[],
|
||||
baseBranch?: string,
|
||||
refreshLocalBaseRef = false
|
||||
): Promise<void> {
|
||||
let created = false
|
||||
try {
|
||||
await addWorktree(repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, true)
|
||||
created = true
|
||||
await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath })
|
||||
await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath })
|
||||
await gitExecFileAsync(['checkout', branch], { cwd: worktreePath })
|
||||
} catch (error) {
|
||||
const wrapped: SparseWorktreeCreateError =
|
||||
error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error))
|
||||
if (created) {
|
||||
try {
|
||||
await removeWorktree(repoPath, worktreePath, true)
|
||||
} catch {
|
||||
wrapped.cleanupFailed = true
|
||||
// Why: the user needs to know that manual cleanup may be required —
|
||||
// otherwise a half-created worktree silently lingers on disk.
|
||||
wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)`
|
||||
}
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worktree.
|
||||
*/
|
||||
|
|
@ -208,3 +272,57 @@ export async function removeWorktree(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseNullDelimitedWorktreeBlocks(output: string): string[][] {
|
||||
const blocks: string[][] = []
|
||||
let current: string[] = []
|
||||
|
||||
for (const token of output.split('\0')) {
|
||||
if (!token) {
|
||||
if (current.length > 0) {
|
||||
blocks.push(current)
|
||||
current = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.push(token)
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
blocks.push(current)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function translateWorktreePath(worktreePath: string, repoPath: string): string {
|
||||
const prefix = 'worktree '
|
||||
const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath)
|
||||
return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath
|
||||
}
|
||||
|
||||
async function detectSparseCheckout(worktreePath: string): Promise<boolean> {
|
||||
// Why: `listWorktrees` runs on every 3-second git-status poll and on every
|
||||
// worktree refresh, so this probe fires N times per poll for N worktrees.
|
||||
// The previous `git sparse-checkout list` subprocess made that N*poll extra
|
||||
// git processes, which regressed app responsiveness on machines with many
|
||||
// worktrees (see PR #1131 revert in #1290). A single fs.stat on the
|
||||
// per-worktree sparse-checkout config file is ~two orders of magnitude
|
||||
// cheaper and has the same truthiness semantics: Git writes this file when
|
||||
// sparse checkout is enabled for the worktree and does not write it
|
||||
// otherwise.
|
||||
//
|
||||
// Why per-worktree gitdir and not `<worktreePath>/.git/info/sparse-checkout`:
|
||||
// linked worktrees have a `.git` file that points at
|
||||
// `<repo>/.git/worktrees/<name>`, and that is where Git stores the
|
||||
// worktree-local sparse-checkout config. `core.sparseCheckout` itself is
|
||||
// shared across all worktrees, so the presence of the config file is the
|
||||
// correct per-worktree signal.
|
||||
try {
|
||||
const gitDir = await resolveGitDir(worktreePath)
|
||||
const stats = await stat(join(gitDir, 'info', 'sparse-checkout'))
|
||||
return stats.isFile() && stats.size > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as CryptoModule from 'crypto'
|
||||
import type { SparsePreset } from '../../shared/types'
|
||||
|
||||
const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
randomUUIDMock: vi.fn(() => 'preset-new'),
|
||||
mockStore: {
|
||||
getRepos: vi.fn().mockReturnValue([]),
|
||||
addRepo: vi.fn(),
|
||||
removeRepo: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
updateRepo: vi.fn(),
|
||||
getSparsePresets: vi.fn(),
|
||||
saveSparsePreset: vi.fn(),
|
||||
removeSparsePreset: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof CryptoModule>()
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: randomUUIDMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
ipcMain: {
|
||||
handle: handleMock,
|
||||
removeHandler: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getGitUsername: vi.fn().mockReturnValue(''),
|
||||
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
|
||||
searchBaseRefs: vi.fn().mockResolvedValue([]),
|
||||
BASE_REF_SEARCH_ARGS: ['for-each-ref'],
|
||||
filterBaseRefSearchOutput: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-auth', () => ({
|
||||
rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
|
||||
type HandlerMap = Map<string, (_event: unknown, args: unknown) => unknown>
|
||||
|
||||
function makePreset(
|
||||
overrides: Partial<SparsePreset> & { id: string; repoId: string }
|
||||
): SparsePreset {
|
||||
return {
|
||||
name: overrides.id,
|
||||
directories: ['packages/web'],
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('sparse preset repo IPC handlers', () => {
|
||||
const handlers: HandlerMap = new Map()
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
randomUUIDMock.mockReturnValue('preset-new')
|
||||
mainWindow.webContents.send.mockReset()
|
||||
mockStore.getRepo.mockReset().mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
})
|
||||
mockStore.getSparsePresets.mockReset().mockReturnValue([])
|
||||
mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset)
|
||||
mockStore.removeSparsePreset.mockReset()
|
||||
|
||||
registerRepoHandlers(mainWindow as never, mockStore as never)
|
||||
})
|
||||
|
||||
it('normalizes and de-duplicates saved sparse preset directories', () => {
|
||||
const saved = handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'repo-1',
|
||||
name: ' Web preset ',
|
||||
directories: [' packages/web ', 'apps\\api\\', 'packages/web/', '.', '']
|
||||
})
|
||||
|
||||
expect(mockStore.saveSparsePreset).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'preset-new',
|
||||
repoId: 'repo-1',
|
||||
name: 'Web preset',
|
||||
directories: ['packages/web', 'apps/api']
|
||||
})
|
||||
)
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Web preset',
|
||||
directories: ['packages/web', 'apps/api']
|
||||
})
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('sparsePresets:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves createdAt and id when editing an existing preset', () => {
|
||||
mockStore.getSparsePresets.mockReturnValue([
|
||||
makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Old', createdAt: 123 })
|
||||
])
|
||||
|
||||
const saved = handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'repo-1',
|
||||
id: 'preset-1',
|
||||
name: 'New',
|
||||
directories: ['packages/new']
|
||||
})
|
||||
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'preset-1',
|
||||
name: 'New',
|
||||
createdAt: 123,
|
||||
directories: ['packages/new']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid sparse preset saves before persistence', () => {
|
||||
expect(() =>
|
||||
handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'Web',
|
||||
directories: ['../secrets']
|
||||
})
|
||||
).toThrow('Preset directories must be repo-relative paths.')
|
||||
|
||||
expect(() =>
|
||||
handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'repo-1',
|
||||
name: ' ',
|
||||
directories: ['packages/web']
|
||||
})
|
||||
).toThrow('Preset name is required.')
|
||||
|
||||
expect(() =>
|
||||
handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'Web',
|
||||
directories: ['.', '']
|
||||
})
|
||||
).toThrow('Preset must have at least one directory.')
|
||||
|
||||
expect(mockStore.saveSparsePreset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/Users/me/repo/packages/web',
|
||||
'C:\\repo\\packages\\web',
|
||||
'\\\\server\\share\\repo',
|
||||
'\\repo\\packages\\web'
|
||||
])('rejects absolute sparse preset directory before normalization: %s', (directory) => {
|
||||
expect(() =>
|
||||
handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'Web',
|
||||
directories: ['packages/web', directory]
|
||||
})
|
||||
).toThrow('Preset directories must be repo-relative paths.')
|
||||
|
||||
expect(mockStore.saveSparsePreset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects sparse preset saves for unknown repos', () => {
|
||||
mockStore.getRepo.mockReturnValue(null)
|
||||
|
||||
expect(() =>
|
||||
handlers.get('sparsePresets:save')!(null, {
|
||||
repoId: 'missing',
|
||||
name: 'Web',
|
||||
directories: ['packages/web']
|
||||
})
|
||||
).toThrow('Repo "missing" not found')
|
||||
})
|
||||
|
||||
it('rejects sparse preset removals for unknown repos before persistence', () => {
|
||||
mockStore.getRepo.mockReturnValue(null)
|
||||
|
||||
expect(() =>
|
||||
handlers.get('sparsePresets:remove')!(null, {
|
||||
repoId: 'missing',
|
||||
presetId: 'preset-1'
|
||||
})
|
||||
).toThrow('Repo "missing" not found')
|
||||
|
||||
expect(mockStore.removeSparsePreset).not.toHaveBeenCalled()
|
||||
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('sparsePresets:changed', {
|
||||
repoId: 'missing'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import type { BrowserWindow } from 'electron'
|
|||
import { dialog, ipcMain } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo, BaseRefDefaultResult } from '../../shared/types'
|
||||
import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { REPO_COLORS } from '../../shared/constants'
|
||||
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from '../git/repo'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
|
||||
// Why: module-scoped so the abort handle survives window re-creation on macOS.
|
||||
// registerRepoHandlers is called again when a new BrowserWindow is created,
|
||||
|
|
@ -50,6 +51,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
ipcMain.removeHandler('repos:getBaseRefDefault')
|
||||
ipcMain.removeHandler('repos:searchBaseRefs')
|
||||
ipcMain.removeHandler('repos:addRemote')
|
||||
ipcMain.removeHandler('sparsePresets:list')
|
||||
ipcMain.removeHandler('sparsePresets:save')
|
||||
ipcMain.removeHandler('sparsePresets:remove')
|
||||
|
||||
ipcMain.handle('repos:list', () => {
|
||||
return store.getRepos()
|
||||
|
|
@ -219,6 +223,55 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
}
|
||||
)
|
||||
|
||||
// ── Sparse presets ─────────────────────────────────────────────
|
||||
// Why: presets are repo-scoped reusable directory lists used by the
|
||||
// new-workspace composer. Persisted via Store and broadcast back to the
|
||||
// renderer so any open composer reflects new/edited/deleted presets
|
||||
// immediately.
|
||||
|
||||
ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => {
|
||||
return store.getSparsePresets(args.repoId)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'sparsePresets:save',
|
||||
(
|
||||
_event,
|
||||
args: { repoId: string; id?: string; name: string; directories: string[] }
|
||||
): SparsePreset => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo "${args.repoId}" not found`)
|
||||
}
|
||||
const name = normalizeSparsePresetName(args.name)
|
||||
const directories = normalizeSparsePresetDirectories(args.directories)
|
||||
const now = Date.now()
|
||||
const existing = args.id
|
||||
? store.getSparsePresets(args.repoId).find((preset) => preset.id === args.id)
|
||||
: undefined
|
||||
const preset: SparsePreset = {
|
||||
id: existing?.id ?? randomUUID(),
|
||||
repoId: args.repoId,
|
||||
name,
|
||||
directories,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
}
|
||||
const saved = store.saveSparsePreset(preset)
|
||||
notifySparsePresetsChanged(mainWindow, args.repoId)
|
||||
return saved
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('sparsePresets:remove', (_event, args: { repoId: string; presetId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo "${args.repoId}" not found`)
|
||||
}
|
||||
store.removeSparsePreset(args.repoId, args.presetId)
|
||||
notifySparsePresetsChanged(mainWindow, args.repoId)
|
||||
})
|
||||
|
||||
ipcMain.handle('repos:pickFolder', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory']
|
||||
|
|
@ -514,3 +567,39 @@ function notifyReposChanged(mainWindow: BrowserWindow): void {
|
|||
mainWindow.webContents.send('repos:changed')
|
||||
}
|
||||
}
|
||||
|
||||
function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('sparsePresets:changed', { repoId })
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSparsePresetName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('Preset name is required.')
|
||||
}
|
||||
if (trimmed.length > 80) {
|
||||
throw new Error('Preset name is too long.')
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function normalizeSparsePresetDirectories(directories: string[]): string[] {
|
||||
let normalized: string[]
|
||||
try {
|
||||
normalized = normalizeSparseDirectories(directories)
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message === 'Sparse checkout directories must be repo-relative paths.'
|
||||
) {
|
||||
throw new Error('Preset directories must be repo-relative paths.')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('Preset must have at least one directory.')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
|
||||
|
||||
function isAbsoluteSparseDirectoryPath(entry: string): boolean {
|
||||
return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
|
||||
}
|
||||
|
||||
export function normalizeSparseDirectories(directories: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
return directories
|
||||
.map((entry) => entry.trim())
|
||||
.map((entry) => {
|
||||
// Why: absolute paths can look repo-relative after slash normalization.
|
||||
if (isAbsoluteSparseDirectoryPath(entry)) {
|
||||
throw new Error('Sparse checkout directories must be repo-relative paths.')
|
||||
}
|
||||
return entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||
})
|
||||
.filter((entry) => entry.length > 0 && entry !== '.')
|
||||
.filter((entry) => {
|
||||
if (entry.split('/').includes('..')) {
|
||||
throw new Error('Sparse checkout directories must be repo-relative paths.')
|
||||
}
|
||||
if (seen.has(entry)) {
|
||||
return false
|
||||
}
|
||||
seen.add(entry)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
@ -160,6 +160,7 @@ export function mergeWorktree(
|
|||
head: git.head,
|
||||
branch: git.branch,
|
||||
isBare: git.isBare,
|
||||
...(git.isSparse === true ? { isSparse: true } : {}),
|
||||
isMainWorktree: git.isMainWorktree,
|
||||
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
|
||||
comment: meta?.comment || '',
|
||||
|
|
@ -171,6 +172,13 @@ export function mergeWorktree(
|
|||
isPinned: meta?.isPinned ?? false,
|
||||
sortOrder: meta?.sortOrder ?? 0,
|
||||
lastActivityAt: meta?.lastActivityAt ?? 0,
|
||||
...(git.isSparse === true
|
||||
? {
|
||||
sparseDirectories: meta?.sparseDirectories,
|
||||
sparseBaseRef: meta?.sparseBaseRef,
|
||||
sparsePresetId: meta?.sparsePresetId
|
||||
}
|
||||
: {}),
|
||||
// Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in
|
||||
// shared/types) and forwarded verbatim so the renderer store mirrors
|
||||
// on-disk state. `undefined` here means the worktree has no comments yet.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { getPRForBranch } from '../github/client'
|
||||
import { listWorktrees, addWorktree } from '../git/worktree'
|
||||
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
|
||||
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
areWorktreePathsEqual
|
||||
} from './worktree-logic'
|
||||
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
|
||||
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
|
|
@ -44,6 +45,10 @@ export async function createRemoteWorktree(
|
|||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): Promise<CreateWorktreeResult> {
|
||||
if (args.sparseCheckout) {
|
||||
throw new Error('Sparse checkout is not supported for remote SSH repos yet.')
|
||||
}
|
||||
|
||||
const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined
|
||||
if (!provider) {
|
||||
throw new Error(`No git provider for connection "${repo.connectionId}"`)
|
||||
|
|
@ -307,6 +312,32 @@ export async function createLocalWorktree(
|
|||
// Resolve it before mutating git state so missing UI input cannot strand
|
||||
// a real worktree on disk while the renderer reports "create failed".
|
||||
const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false
|
||||
const sparseDirectories = args.sparseCheckout
|
||||
? normalizeSparseDirectories(args.sparseCheckout.directories)
|
||||
: []
|
||||
if (args.sparseCheckout && sparseDirectories.length === 0) {
|
||||
throw new Error('Sparse checkout requires at least one repo-relative directory.')
|
||||
}
|
||||
let sparsePresetId: string | undefined
|
||||
if (args.sparseCheckout?.presetId) {
|
||||
const preset = store
|
||||
.getSparsePresets(repo.id)
|
||||
.find((entry) => entry.id === args.sparseCheckout?.presetId)
|
||||
if (preset?.repoId === repo.id) {
|
||||
try {
|
||||
const presetDirectories = normalizeSparseDirectories(preset.directories)
|
||||
// Why: use Set-based comparison so directory order does not affect
|
||||
// attribution — matches the renderer's sparseDirectoriesMatch logic.
|
||||
const presetSet = new Set(presetDirectories)
|
||||
const directoriesMatch =
|
||||
presetDirectories.length === sparseDirectories.length &&
|
||||
sparseDirectories.every((entry) => presetSet.has(entry))
|
||||
sparsePresetId = directoriesMatch ? preset.id : undefined
|
||||
} catch {
|
||||
// Why: corrupt preset data should not block creation or falsely label the new worktree.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: `git fetch` previously blocked worktree creation for 1–5s on every
|
||||
// click, even though the fetch result isn't actually required — the
|
||||
|
|
@ -319,13 +350,22 @@ export async function createLocalWorktree(
|
|||
// Fetch is best-effort — don't block worktree creation if offline
|
||||
})
|
||||
|
||||
await addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
await (sparseDirectories.length > 0
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))
|
||||
|
||||
// Re-list to get the freshly created worktree info
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
|
|
@ -342,6 +382,13 @@ export async function createLocalWorktree(
|
|||
lastActivityAt: Date.now(),
|
||||
...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
|
||||
? { displayName: effectiveRequestedName }
|
||||
: {}),
|
||||
...(sparseDirectories.length > 0
|
||||
? {
|
||||
sparseDirectories,
|
||||
sparseBaseRef: baseBranch,
|
||||
sparsePresetId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const {
|
|||
removeHandlerMock,
|
||||
listWorktreesMock,
|
||||
addWorktreeMock,
|
||||
addSparseWorktreeMock,
|
||||
removeWorktreeMock,
|
||||
getGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
|
|
@ -26,6 +27,7 @@ const {
|
|||
removeHandlerMock: vi.fn(),
|
||||
listWorktreesMock: vi.fn(),
|
||||
addWorktreeMock: vi.fn(),
|
||||
addSparseWorktreeMock: vi.fn(),
|
||||
removeWorktreeMock: vi.fn(),
|
||||
getGitUsernameMock: vi.fn(),
|
||||
getDefaultBaseRefMock: vi.fn(),
|
||||
|
|
@ -53,6 +55,7 @@ vi.mock('electron', () => ({
|
|||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: listWorktreesMock,
|
||||
addWorktree: addWorktreeMock,
|
||||
addSparseWorktree: addSparseWorktreeMock,
|
||||
removeWorktree: removeWorktreeMock
|
||||
}))
|
||||
|
||||
|
|
@ -113,6 +116,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
const store = {
|
||||
getRepos: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
getSparsePresets: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
|
|
@ -125,6 +129,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
removeHandlerMock,
|
||||
listWorktreesMock,
|
||||
addWorktreeMock,
|
||||
addSparseWorktreeMock,
|
||||
removeWorktreeMock,
|
||||
getGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
|
|
@ -143,6 +148,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
mainWindow.webContents.send,
|
||||
store.getRepos,
|
||||
store.getRepo,
|
||||
store.getSparsePresets,
|
||||
store.getSettings,
|
||||
store.getWorktreeMeta,
|
||||
store.setWorktreeMeta,
|
||||
|
|
@ -168,6 +174,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
|
||||
store.getSparsePresets.mockReturnValue([])
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
|
|
@ -471,6 +478,173 @@ describe('registerWorktreeHandlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('creates a sparse worktree and persists its sparse metadata', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
...createdWorktreeList[0],
|
||||
isSparse: true
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
sparseDirectories: ['packages/web', 'apps/api'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
store.getSparsePresets.mockReturnValue([
|
||||
{
|
||||
id: 'preset-1',
|
||||
repoId: 'repo-1',
|
||||
name: 'Frontend and API',
|
||||
directories: ['packages/web', 'apps/api'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
sparseCheckout: {
|
||||
directories: [' packages/web ', 'apps\\api\\', 'packages/web/'],
|
||||
presetId: 'preset-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(addSparseWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/improve-dashboard',
|
||||
'improve-dashboard',
|
||||
['packages/web', 'apps/api'],
|
||||
'origin/main',
|
||||
false
|
||||
)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['packages/web', 'apps/api'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
worktree: expect.objectContaining({
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/improve-dashboard',
|
||||
sparseDirectories: ['packages/web', 'apps/api'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears sparse preset attribution when the preset id does not belong to the repo', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
...createdWorktreeList[0],
|
||||
isSparse: true
|
||||
}
|
||||
])
|
||||
store.getSparsePresets.mockReturnValue([
|
||||
{
|
||||
id: 'preset-2',
|
||||
repoId: 'repo-1',
|
||||
name: 'Other preset',
|
||||
directories: ['packages/web'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
sparseCheckout: {
|
||||
directories: ['packages/web'],
|
||||
presetId: 'preset-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['packages/web'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears sparse preset attribution when normalized directories do not match', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
...createdWorktreeList[0],
|
||||
isSparse: true
|
||||
}
|
||||
])
|
||||
store.getSparsePresets.mockReturnValue([
|
||||
{
|
||||
id: 'preset-1',
|
||||
repoId: 'repo-1',
|
||||
name: 'Frontend and API',
|
||||
directories: ['packages/web', 'apps/api'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
sparseCheckout: {
|
||||
directories: ['packages/web'],
|
||||
presetId: 'preset-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/improve-dashboard',
|
||||
expect.objectContaining({
|
||||
sparseDirectories: ['packages/web'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects sparse checkout directories that traverse above the repo root', async () => {
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
sparseCheckout: {
|
||||
directories: ['packages/web', '../secrets']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
|
||||
|
||||
expect(addSparseWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
|
||||
'rejects absolute sparse checkout directory before normalization: %s',
|
||||
async (directory) => {
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
sparseCheckout: {
|
||||
directories: ['packages/web', directory]
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Sparse checkout directories must be repo-relative paths.')
|
||||
|
||||
expect(addSparseWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('still returns the created worktree when setup runner generation fails', async () => {
|
||||
listWorktreesMock.mockResolvedValue(createdWorktreeList)
|
||||
getEffectiveHooksMock.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
BrowserSessionProfileSource,
|
||||
ClaudeRateLimitAccountsState,
|
||||
CodexRateLimitAccountsState,
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
DirEntry,
|
||||
FsChangedPayload,
|
||||
|
|
@ -46,6 +47,7 @@ import type {
|
|||
PRComment,
|
||||
PRInfo,
|
||||
Repo,
|
||||
SparsePreset,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
StatsSummary,
|
||||
|
|
@ -329,15 +331,21 @@ export type PreloadApi = {
|
|||
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
|
||||
onChanged: (callback: () => void) => () => void
|
||||
}
|
||||
sparsePresets: {
|
||||
list: (args: { repoId: string }) => Promise<SparsePreset[]>
|
||||
save: (args: {
|
||||
repoId: string
|
||||
id?: string
|
||||
name: string
|
||||
directories: string[]
|
||||
}) => Promise<SparsePreset>
|
||||
remove: (args: { repoId: string; presetId: string }) => Promise<void>
|
||||
onChanged: (callback: (data: { repoId: string }) => void) => () => void
|
||||
}
|
||||
worktrees: {
|
||||
list: (args: { repoId: string }) => Promise<Worktree[]>
|
||||
listAll: () => Promise<Worktree[]>
|
||||
create: (args: {
|
||||
repoId: string
|
||||
name: string
|
||||
baseBranch?: string
|
||||
setupDecision?: 'inherit' | 'run' | 'skip'
|
||||
}) => Promise<CreateWorktreeResult>
|
||||
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
|
||||
resolvePrBase: (args: {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types'
|
|||
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
||||
import type {
|
||||
BaseRefDefaultResult,
|
||||
CreateWorktreeArgs,
|
||||
FsChangedPayload,
|
||||
GitHubAssignableUser,
|
||||
GitHubCommentResult,
|
||||
|
|
@ -246,18 +247,36 @@ const api = {
|
|||
}
|
||||
},
|
||||
|
||||
sparsePresets: {
|
||||
list: (args: { repoId: string }): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('sparsePresets:list', args),
|
||||
|
||||
save: (args: {
|
||||
repoId: string
|
||||
id?: string
|
||||
name: string
|
||||
directories: string[]
|
||||
}): Promise<unknown> => ipcRenderer.invoke('sparsePresets:save', args),
|
||||
|
||||
remove: (args: { repoId: string; presetId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('sparsePresets:remove', args),
|
||||
|
||||
onChanged: (callback: (data: { repoId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) =>
|
||||
callback(data)
|
||||
ipcRenderer.on('sparsePresets:changed', listener)
|
||||
return () => ipcRenderer.removeListener('sparsePresets:changed', listener)
|
||||
}
|
||||
},
|
||||
|
||||
worktrees: {
|
||||
list: (args: { repoId: string }): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('worktrees:list', args),
|
||||
|
||||
listAll: (): Promise<unknown[]> => ipcRenderer.invoke('worktrees:listAll'),
|
||||
|
||||
create: (args: {
|
||||
repoId: string
|
||||
name: string
|
||||
baseBranch?: string
|
||||
setupDecision?: 'inherit' | 'run' | 'skip'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('worktrees:create', args),
|
||||
create: (args: CreateWorktreeArgs): Promise<unknown> =>
|
||||
ipcRenderer.invoke('worktrees:create', args),
|
||||
|
||||
resolvePrBase: (args: {
|
||||
repoId: string
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
|
|||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import type { SparsePreset, TuiAgent } from '../../../shared/types'
|
||||
import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect'
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
|
||||
|
|
@ -51,6 +52,10 @@ type NewWorkspaceComposerCardProps = {
|
|||
shouldWaitForSetupCheck: boolean
|
||||
resolvedSetupDecision: 'run' | 'skip' | null
|
||||
createError: string | null
|
||||
canUseSparseCheckout: boolean
|
||||
sparsePresets: SparsePreset[]
|
||||
sparseSelectedPresetId: string | null
|
||||
onSparseSelectPreset: (preset: SparsePreset | null) => void
|
||||
}
|
||||
|
||||
function SetupCommandPreview({
|
||||
|
|
@ -186,7 +191,11 @@ export default function NewWorkspaceComposerCard({
|
|||
onSetupDecisionChange,
|
||||
shouldWaitForSetupCheck,
|
||||
resolvedSetupDecision,
|
||||
createError
|
||||
createError,
|
||||
canUseSparseCheckout,
|
||||
sparsePresets,
|
||||
sparseSelectedPresetId,
|
||||
onSparseSelectPreset
|
||||
}: NewWorkspaceComposerCardProps): React.JSX.Element {
|
||||
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
|
@ -337,8 +346,8 @@ export default function NewWorkspaceComposerCard({
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||
advancedOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
|
||||
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-hidden={!advancedOpen}
|
||||
>
|
||||
|
|
@ -347,7 +356,14 @@ export default function NewWorkspaceComposerCard({
|
|||
textarea's 3px outset focus ring has horizontal breathing room
|
||||
inside the overflow-hidden drawer above. Without it the ring
|
||||
gets clipped on the right edge when the field is focused. */}
|
||||
<div className="space-y-4 px-1 pt-1">
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-4 px-1 pt-1 pb-3 transition-[opacity,transform] duration-150 ease-out',
|
||||
advancedOpen
|
||||
? 'translate-y-0 opacity-100 delay-200'
|
||||
: '-translate-y-1 opacity-0 delay-0'
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Note</label>
|
||||
<textarea
|
||||
|
|
@ -449,6 +465,22 @@ export default function NewWorkspaceComposerCard({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sparse checkout</label>
|
||||
<SparseCheckoutPresetSelect
|
||||
repoId={repoId}
|
||||
presets={sparsePresets}
|
||||
selectedPresetId={sparseSelectedPresetId}
|
||||
onSelectPreset={onSparseSelectPreset}
|
||||
disabled={!canUseSparseCheckout}
|
||||
/>
|
||||
{!canUseSparseCheckout ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Only available for local repositories.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -476,7 +476,10 @@ function AnimatedTabPanels({
|
|||
// leaving no room for a ring to paint. `overflow-clip-margin` gives
|
||||
// the ring breathing room on every side without re-introducing scroll
|
||||
// containers or letting the inactive panel leak layout.
|
||||
className="relative overflow-clip transition-[height] duration-200 ease-out"
|
||||
className={cn(
|
||||
'relative overflow-clip',
|
||||
isAnimating && 'transition-[height] duration-200 ease-out'
|
||||
)}
|
||||
style={{
|
||||
...(wrapperHeight !== null ? { height: wrapperHeight } : null),
|
||||
overflowClipMargin: '8px'
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react'
|
|||
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
|
||||
import { BaseRefPicker } from './BaseRefPicker'
|
||||
import { RepositoryHooksSection } from './RepositoryHooksSection'
|
||||
import { SparsePresetSettingsSection } from './SparsePresetSettingsSection'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
|
|
@ -43,6 +44,20 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
title: 'Default Worktree Base',
|
||||
description: 'Default base branch or ref when creating worktrees.',
|
||||
keywords: [repo.displayName, 'base ref', 'branch']
|
||||
},
|
||||
{
|
||||
title: 'Sparse Checkout Presets',
|
||||
description: 'Saved directory sets for sparse worktree creation.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'sparse',
|
||||
'checkout',
|
||||
'preset',
|
||||
'presets',
|
||||
'directory',
|
||||
'directories',
|
||||
'monorepo'
|
||||
]
|
||||
}
|
||||
]),
|
||||
{
|
||||
|
|
@ -164,6 +179,9 @@ export function RepositoryPane({
|
|||
const identityEntries = allEntries.filter((entry) =>
|
||||
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
|
||||
)
|
||||
const sparsePresetEntries = allEntries.filter((entry) =>
|
||||
['Sparse Checkout Presets'].includes(entry.title)
|
||||
)
|
||||
const hooksEntries = allEntries.filter((entry) =>
|
||||
[
|
||||
'orca.yaml hooks',
|
||||
|
|
@ -269,6 +287,9 @@ export function RepositoryPane({
|
|||
) : null}
|
||||
</section>
|
||||
) : null,
|
||||
!isFolder && matchesSettingsSearch(searchQuery, sparsePresetEntries) ? (
|
||||
<SparsePresetSettingsSection key="sparse-presets" repoId={repo.id} />
|
||||
) : null,
|
||||
!isFolder && matchesSettingsSearch(searchQuery, hooksEntries) ? (
|
||||
<RepositoryHooksSection
|
||||
key="hooks"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bookmark, LoaderCircle, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
|
||||
import type { SparsePreset } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { formatSparsePresetUpdatedAt } from './sparse-preset-date'
|
||||
|
||||
type SparsePresetSettingsSectionProps = {
|
||||
repoId: string
|
||||
}
|
||||
|
||||
type SparsePresetDraft = {
|
||||
mode: 'new' | 'edit'
|
||||
presetId?: string
|
||||
name: string
|
||||
directoriesText: string
|
||||
}
|
||||
|
||||
function SparsePresetDirectoryPreview({
|
||||
directories
|
||||
}: {
|
||||
directories: string[]
|
||||
}): React.JSX.Element {
|
||||
const visibleDirectories = directories.slice(0, 6)
|
||||
const hiddenCount = directories.length - visibleDirectories.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleDirectories.map((directory) => (
|
||||
<span
|
||||
key={directory}
|
||||
className="min-w-0 max-w-full truncate rounded-md border border-border/50 bg-muted/35 px-2 py-1 font-mono text-[11px] text-foreground/80"
|
||||
title={directory}
|
||||
>
|
||||
{directory}
|
||||
</span>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<span className="rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground">
|
||||
+{hiddenCount} more
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SparsePresetSettingsSection({
|
||||
repoId
|
||||
}: SparsePresetSettingsSectionProps): React.JSX.Element {
|
||||
const presets = useAppStore((s) => s.sparsePresetsByRepo[repoId])
|
||||
const fetchSparsePresets = useAppStore((s) => s.fetchSparsePresets)
|
||||
const saveSparsePreset = useAppStore((s) => s.saveSparsePreset)
|
||||
const removeSparsePreset = useAppStore((s) => s.removeSparsePreset)
|
||||
|
||||
const [draft, setDraft] = useState<SparsePresetDraft | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (presets === undefined) {
|
||||
void fetchSparsePresets(repoId)
|
||||
}
|
||||
}, [fetchSparsePresets, presets, repoId])
|
||||
|
||||
const sortedPresets = presets ?? []
|
||||
const parsedDirectories = draft ? parseSparsePresetDirectories(draft.directoriesText) : null
|
||||
const trimmedName = draft?.name.trim() ?? ''
|
||||
const lowerName = trimmedName.toLowerCase()
|
||||
const collidingPreset =
|
||||
draft && trimmedName
|
||||
? (sortedPresets.find(
|
||||
(preset) => preset.id !== draft.presetId && preset.name.toLowerCase() === lowerName
|
||||
) ?? null)
|
||||
: null
|
||||
|
||||
const nameError =
|
||||
draft && trimmedName.length === 0
|
||||
? 'Name is required.'
|
||||
: trimmedName.length > 80
|
||||
? 'Name must be 80 characters or fewer.'
|
||||
: collidingPreset
|
||||
? `"${collidingPreset.name}" already exists.`
|
||||
: null
|
||||
const canSaveDraft =
|
||||
!!draft && !submitting && !nameError && parsedDirectories !== null && !parsedDirectories.error
|
||||
|
||||
const startNewPreset = (): void => {
|
||||
setConfirmingDeleteId(null)
|
||||
setDraft({
|
||||
mode: 'new',
|
||||
name: '',
|
||||
directoriesText: ''
|
||||
})
|
||||
}
|
||||
|
||||
const startEditPreset = (preset: SparsePreset): void => {
|
||||
setConfirmingDeleteId(null)
|
||||
setDraft({
|
||||
mode: 'edit',
|
||||
presetId: preset.id,
|
||||
name: preset.name,
|
||||
directoriesText: preset.directories.join('\n')
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveDraft = async (): Promise<void> => {
|
||||
if (!draft || !canSaveDraft || !parsedDirectories) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const saved = await saveSparsePreset({
|
||||
repoId,
|
||||
id: draft.presetId,
|
||||
name: trimmedName,
|
||||
directories: parsedDirectories.directories
|
||||
})
|
||||
if (saved) {
|
||||
setDraft(null)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePreset = async (preset: SparsePreset): Promise<void> => {
|
||||
if (confirmingDeleteId !== preset.id) {
|
||||
setConfirmingDeleteId(preset.id)
|
||||
return
|
||||
}
|
||||
if (draft?.presetId === preset.id) {
|
||||
setDraft(null)
|
||||
}
|
||||
setConfirmingDeleteId(null)
|
||||
await removeSparsePreset({ repoId, presetId: preset.id })
|
||||
}
|
||||
|
||||
const renderDraftEditor = (): React.JSX.Element | null => {
|
||||
if (!draft) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-background/80 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-semibold">
|
||||
{draft.mode === 'new' ? 'New Preset' : 'Edit Preset'}
|
||||
</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved directories are used when creating sparse worktrees for this repository.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Cancel preset edit"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sparse-preset-settings-name">Name</Label>
|
||||
<Input
|
||||
id="sparse-preset-settings-name"
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
||||
placeholder="e.g. web-only"
|
||||
maxLength={80}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
{nameError ? <p className="text-xs text-destructive">{nameError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sparse-preset-settings-directories">Directories</Label>
|
||||
<textarea
|
||||
id="sparse-preset-settings-directories"
|
||||
value={draft.directoriesText}
|
||||
onChange={(event) => setDraft({ ...draft, directoriesText: event.target.value })}
|
||||
placeholder={`packages/web\nshared/ui`}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
className="w-full min-w-0 resize-y rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
{parsedDirectories?.error ? (
|
||||
<p className="text-xs text-destructive">{parsedDirectories.error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{parsedDirectories?.directories.length === 1
|
||||
? '1 directory will be saved.'
|
||||
: `${parsedDirectories?.directories.length ?? 0} directories will be saved.`}{' '}
|
||||
Use repo-relative paths like packages/web or apps/api.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSaveDraft()}
|
||||
disabled={!canSaveDraft}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3.5" />
|
||||
)}
|
||||
Save Preset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Sparse Checkout Presets</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage saved directory sets for sparse worktree creation.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={startNewPreset}
|
||||
disabled={!!draft}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New Preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{renderDraftEditor()}
|
||||
|
||||
{presets === undefined ? (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-background/60 px-4 py-6 text-sm text-muted-foreground">
|
||||
Loading sparse presets...
|
||||
</div>
|
||||
) : sortedPresets.length === 0 && !draft ? (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-background/60 px-4 py-6 text-sm text-muted-foreground">
|
||||
No sparse presets saved for this repository.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sortedPresets.map((preset) => {
|
||||
// Why: users can already have locally persisted presets from older
|
||||
// builds or hand-edited state; a bad timestamp must not blank Settings.
|
||||
const updatedLabel = formatSparsePresetUpdatedAt(preset.updatedAt)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="rounded-xl border border-border/50 bg-background/70 px-4 py-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-muted/30">
|
||||
<Bookmark className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h4 className="min-w-0 truncate text-sm font-medium">{preset.name}</h4>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{preset.directories.length === 1
|
||||
? '1 directory'
|
||||
: `${preset.directories.length} directories`}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{updatedLabel ? `Updated ${updatedLabel}` : 'Updated date unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<SparsePresetDirectoryPreview directories={preset.directories} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${preset.name}`}
|
||||
onClick={() => startEditPreset(preset)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmingDeleteId === preset.id ? 'destructive' : 'ghost'}
|
||||
size="sm"
|
||||
aria-label={`Delete ${preset.name}`}
|
||||
onClick={() => void handleDeletePreset(preset)}
|
||||
onBlur={() => setConfirmingDeleteId(null)}
|
||||
disabled={submitting}
|
||||
className={cn(
|
||||
'w-[5.75rem] px-2 text-xs',
|
||||
confirmingDeleteId !== preset.id && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{confirmingDeleteId === preset.id ? 'Confirm' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { formatSparsePresetUpdatedAt } from './sparse-preset-date'
|
||||
|
||||
describe('formatSparsePresetUpdatedAt', () => {
|
||||
it('formats valid timestamps', () => {
|
||||
expect(formatSparsePresetUpdatedAt(Date.UTC(2026, 0, 2))).toContain('2026')
|
||||
})
|
||||
|
||||
it('returns null for invalid persisted timestamps', () => {
|
||||
expect(formatSparsePresetUpdatedAt(Number.NaN)).toBeNull()
|
||||
expect(formatSparsePresetUpdatedAt(Number.POSITIVE_INFINITY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export function formatSparsePresetUpdatedAt(timestamp: number): string | null {
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(date)
|
||||
}
|
||||
|
|
@ -40,6 +40,11 @@ type WorktreeCardProps = {
|
|||
hintNumber?: number
|
||||
}
|
||||
|
||||
function formatSparseDirectoryPreview(directories: string[]): string {
|
||||
const preview = directories.slice(0, 4).join(', ')
|
||||
return directories.length <= 4 ? preview : `${preview}, +${directories.length - 4} more`
|
||||
}
|
||||
|
||||
const WorktreeCard = React.memo(function WorktreeCard({
|
||||
worktree,
|
||||
repo,
|
||||
|
|
@ -445,6 +450,29 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{worktree.isSparse && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-amber-700 dark:text-amber-300 border-amber-500/30 bg-amber-500/5"
|
||||
>
|
||||
sparse
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-72">
|
||||
<div className="space-y-1">
|
||||
<div>Partial checkout. Files outside these paths are not on disk.</div>
|
||||
{worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? (
|
||||
<div className="font-mono text-[11px] opacity-80">
|
||||
{formatSparseDirectoryPreview(worktree.sparseDirectories)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CI Checks & PR state on the right */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronsUpDown, LoaderCircle, Pencil, Plus, RefreshCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft'
|
||||
import type { SparsePreset } from '../../../../shared/types'
|
||||
|
||||
type SparseCheckoutPresetSelectProps = {
|
||||
repoId: string
|
||||
presets: SparsePreset[]
|
||||
selectedPresetId: string | null
|
||||
onSelectPreset: (preset: SparsePreset | null) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type PresetDraft = {
|
||||
mode: 'new' | 'edit'
|
||||
presetId?: string
|
||||
name: string
|
||||
directoriesText: string
|
||||
}
|
||||
|
||||
export default function SparseCheckoutPresetSelect({
|
||||
repoId,
|
||||
presets,
|
||||
selectedPresetId,
|
||||
onSelectPreset,
|
||||
disabled = false
|
||||
}: SparseCheckoutPresetSelectProps): React.JSX.Element {
|
||||
const fetchSparsePresets = useAppStore((s) => s.fetchSparsePresets)
|
||||
const saveSparsePreset = useAppStore((s) => s.saveSparsePreset)
|
||||
const presetsForRepo = useAppStore((s) => s.sparsePresetsByRepo[repoId])
|
||||
const presetsLoadStatus = useAppStore((s) => s.sparsePresetsLoadStatusByRepo[repoId] ?? 'idle')
|
||||
const presetsLoading = presetsLoadStatus === 'loading'
|
||||
const presetsLoadError = useAppStore((s) => s.sparsePresetsErrorByRepo[repoId] ?? null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<PresetDraft | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const visiblePresets = presetsForRepo ?? presets
|
||||
const presetsLoaded = presetsForRepo !== undefined
|
||||
const isLoadingPresets = !disabled && presetsLoading
|
||||
const hasPresetLoadError = !disabled && !presetsLoaded && !!presetsLoadError
|
||||
const selectedPreset = useMemo(
|
||||
() => visiblePresets.find((preset) => preset.id === selectedPresetId) ?? null,
|
||||
[visiblePresets, selectedPresetId]
|
||||
)
|
||||
const parsedDirectories = draft ? parseSparsePresetDirectories(draft.directoriesText) : null
|
||||
const trimmedName = draft?.name.trim() ?? ''
|
||||
const nameCollision =
|
||||
draft && trimmedName
|
||||
? (visiblePresets.find(
|
||||
(preset) =>
|
||||
preset.id !== draft.presetId && preset.name.toLowerCase() === trimmedName.toLowerCase()
|
||||
) ?? null)
|
||||
: null
|
||||
const nameError =
|
||||
draft && trimmedName.length === 0
|
||||
? 'Name is required.'
|
||||
: trimmedName.length > 80
|
||||
? 'Name must be 80 characters or fewer.'
|
||||
: nameCollision
|
||||
? `"${nameCollision.name}" already exists.`
|
||||
: null
|
||||
const canSave =
|
||||
draft !== null &&
|
||||
!submitting &&
|
||||
!disabled &&
|
||||
presetsLoaded &&
|
||||
!nameError &&
|
||||
parsedDirectories !== null &&
|
||||
!parsedDirectories.error
|
||||
|
||||
const startDraft = useCallback(
|
||||
(nextDraft: PresetDraft): void => {
|
||||
if (disabled || !presetsLoaded) {
|
||||
return
|
||||
}
|
||||
setDraft(nextDraft)
|
||||
requestAnimationFrame(() => {
|
||||
nameInputRef.current?.focus()
|
||||
nameInputRef.current?.select()
|
||||
})
|
||||
},
|
||||
[disabled, presetsLoaded]
|
||||
)
|
||||
|
||||
const startNewPreset = useCallback((): void => {
|
||||
startDraft({ mode: 'new', name: '', directoriesText: '' })
|
||||
}, [startDraft])
|
||||
|
||||
const handleRetryLoadPresets = useCallback((): void => {
|
||||
if (disabled || presetsLoading) {
|
||||
return
|
||||
}
|
||||
setDraft(null)
|
||||
void fetchSparsePresets(repoId)
|
||||
}, [disabled, fetchSparsePresets, presetsLoading, repoId])
|
||||
|
||||
const startEditPreset = useCallback(
|
||||
(preset: SparsePreset): void => {
|
||||
startDraft({
|
||||
mode: 'edit',
|
||||
presetId: preset.id,
|
||||
name: preset.name,
|
||||
directoriesText: preset.directories.join('\n')
|
||||
})
|
||||
},
|
||||
[startDraft]
|
||||
)
|
||||
|
||||
const handleSaveDraft = useCallback(async (): Promise<void> => {
|
||||
if (!draft || !canSave || !parsedDirectories) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const saved = await saveSparsePreset({
|
||||
repoId,
|
||||
id: draft.presetId,
|
||||
name: trimmedName,
|
||||
directories: parsedDirectories.directories
|
||||
})
|
||||
if (saved) {
|
||||
if (draft.mode === 'new' || selectedPresetId === saved.id) {
|
||||
onSelectPreset(saved)
|
||||
}
|
||||
setDraft(null)
|
||||
setOpen(false)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
canSave,
|
||||
draft,
|
||||
onSelectPreset,
|
||||
parsedDirectories,
|
||||
repoId,
|
||||
saveSparsePreset,
|
||||
selectedPresetId,
|
||||
trimmedName
|
||||
])
|
||||
|
||||
const handleSelectOff = useCallback((): void => {
|
||||
if (disabled || !presetsLoaded) {
|
||||
return
|
||||
}
|
||||
onSelectPreset(null)
|
||||
setDraft(null)
|
||||
setOpen(false)
|
||||
}, [disabled, onSelectPreset, presetsLoaded])
|
||||
|
||||
const handleSelectPreset = useCallback(
|
||||
(preset: SparsePreset): void => {
|
||||
if (disabled || !presetsLoaded) {
|
||||
return
|
||||
}
|
||||
onSelectPreset(preset)
|
||||
setDraft(null)
|
||||
setOpen(false)
|
||||
},
|
||||
[disabled, onSelectPreset, presetsLoaded]
|
||||
)
|
||||
|
||||
const triggerLabel = isLoadingPresets
|
||||
? 'Loading presets...'
|
||||
: hasPresetLoadError
|
||||
? 'Retry loading presets'
|
||||
: !presetsLoaded
|
||||
? 'Load presets'
|
||||
: selectedPreset
|
||||
? selectedPreset.name
|
||||
: 'Off'
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen && presetsLoading) {
|
||||
setOpen(false)
|
||||
setDraft(null)
|
||||
return
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setDraft(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-busy={isLoadingPresets}
|
||||
disabled={disabled || isLoadingPresets}
|
||||
className="h-9 w-full justify-between px-3 text-sm font-normal text-foreground"
|
||||
>
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
{isLoadingPresets ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin opacity-60" />
|
||||
) : hasPresetLoadError || !presetsLoaded ? (
|
||||
<RefreshCcw className="size-3.5 opacity-60" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] p-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{draft ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleSaveDraft()
|
||||
}}
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium text-foreground">
|
||||
{draft.mode === 'new' ? 'New preset' : 'Edit preset'}
|
||||
</div>
|
||||
<div className="space-y-3 px-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="sparse-preset-name"
|
||||
className="block text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
|
||||
<input
|
||||
id="sparse-preset-name"
|
||||
ref={nameInputRef}
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
||||
placeholder="Renderer UI"
|
||||
maxLength={80}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-8 w-full bg-transparent text-xs text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="sparse-preset-directories"
|
||||
className="block text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
Directories
|
||||
</label>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
|
||||
<textarea
|
||||
id="sparse-preset-directories"
|
||||
value={draft.directoriesText}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, directoriesText: event.target.value })
|
||||
}
|
||||
placeholder={`src/renderer\npackages/ui`}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="max-h-28 w-full min-w-0 resize-none bg-transparent font-mono text-xs leading-5 text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-11 items-center justify-between gap-3 border-t border-border px-3 py-2">
|
||||
<div className="min-w-0 text-[10px] text-muted-foreground">
|
||||
{nameError ? (
|
||||
<span className="text-destructive">{nameError}</span>
|
||||
) : parsedDirectories?.error ? (
|
||||
<span className="text-destructive">{parsedDirectories.error}</span>
|
||||
) : parsedDirectories?.directories.length === 1 ? (
|
||||
'1 directory'
|
||||
) : (
|
||||
`${parsedDirectories?.directories.length ?? 0} directories`
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="h-7 px-2 text-xs" disabled={!canSave}>
|
||||
{submitting ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : !presetsLoaded ? (
|
||||
<div className="p-1">
|
||||
{hasPresetLoadError ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-destructive">
|
||||
<span className="break-words">{presetsLoadError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleRetryLoadPresets}
|
||||
>
|
||||
<RefreshCcw className="size-3.5 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{hasPresetLoadError ? 'Retry loading presets' : 'Load presets'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="mx-1 flex h-9 w-[calc(100%-0.5rem)] items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleSelectOff}
|
||||
>
|
||||
<Check className={cn('size-4', selectedPreset ? 'opacity-0' : 'opacity-100')} />
|
||||
Off
|
||||
</button>
|
||||
</div>
|
||||
{visiblePresets.length > 0 ? (
|
||||
<>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="space-y-0.5 py-1">
|
||||
{visiblePresets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="mx-1 flex items-center rounded-md hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-l-md px-2 text-left text-xs"
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
selectedPreset?.id === preset.id ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Edit ${preset.name}`}
|
||||
className="mr-1 size-7 shrink-0 rounded-md text-muted-foreground hover:bg-background/35 hover:text-foreground"
|
||||
onClick={() => startEditPreset(preset)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={startNewPreset}
|
||||
className="mx-1 my-1 h-8 w-[calc(100%-0.5rem)] justify-start rounded-md px-2 text-xs font-normal"
|
||||
>
|
||||
<Plus className="size-3.5 text-muted-foreground" />
|
||||
New preset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
OrcaHooks,
|
||||
SetupDecision,
|
||||
SetupRunPolicy,
|
||||
SparsePreset,
|
||||
TuiAgent
|
||||
} from '../../../shared/types'
|
||||
import {
|
||||
|
|
@ -35,6 +36,7 @@ import {
|
|||
} from '@/lib/new-workspace'
|
||||
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
|
||||
|
||||
export type UseComposerStateOptions = {
|
||||
initialRepoId?: string
|
||||
|
|
@ -119,6 +121,13 @@ export type ComposerCardProps = {
|
|||
shouldWaitForSetupCheck: boolean
|
||||
resolvedSetupDecision: 'run' | 'skip' | null
|
||||
createError: string | null
|
||||
canUseSparseCheckout: boolean
|
||||
/** Saved presets for the currently-selected repo. Empty array when no
|
||||
* presets exist or when the repo is remote. */
|
||||
sparsePresets: SparsePreset[]
|
||||
/** ID of the selected sparse preset. Null means sparse checkout is off. */
|
||||
sparseSelectedPresetId: string | null
|
||||
onSparseSelectPreset: (preset: SparsePreset | null) => void
|
||||
}
|
||||
|
||||
export type UseComposerStateResult = {
|
||||
|
|
@ -142,6 +151,7 @@ export type UseComposerStateResult = {
|
|||
// modal wins when both are present, and the page takes over once the modal
|
||||
// closes.
|
||||
const composerDropStack: symbol[] = []
|
||||
const EMPTY_SPARSE_PRESETS: SparsePreset[] = []
|
||||
|
||||
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
|
||||
const {
|
||||
|
|
@ -172,7 +182,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
closeModal: s.closeModal,
|
||||
openSettingsPage: s.openSettingsPage,
|
||||
openSettingsTarget: s.openSettingsTarget,
|
||||
prefetchWorkItems: s.prefetchWorkItems
|
||||
prefetchWorkItems: s.prefetchWorkItems,
|
||||
fetchSparsePresets: s.fetchSparsePresets
|
||||
}))
|
||||
)
|
||||
const {
|
||||
|
|
@ -186,7 +197,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
closeModal,
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
prefetchWorkItems
|
||||
prefetchWorkItems,
|
||||
fetchSparsePresets
|
||||
} = actions
|
||||
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
|
|
@ -194,7 +206,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const settings = useAppStore((s) => s.settings)
|
||||
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
|
||||
const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo)
|
||||
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
|
||||
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
|
||||
|
||||
|
|
@ -298,6 +310,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const [advancedOpen, setAdvancedOpen] = useState(
|
||||
persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false
|
||||
)
|
||||
const [sparseEnabled, setSparseEnabled] = useState(false)
|
||||
const [sparseDirectories, setSparseDirectories] = useState('')
|
||||
const [sparseSelectedPresetId, setSparseSelectedPresetId] = useState<string | null>(null)
|
||||
|
||||
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
|
||||
const [linkQuery, setLinkQuery] = useState('')
|
||||
|
|
@ -331,6 +346,46 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
agentPromptRef.current = agentPrompt
|
||||
|
||||
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
|
||||
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
|
||||
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
|
||||
const normalizedSparseDirectories = useMemo(
|
||||
() => normalizeSparseDirectoryLines(sparseDirectories),
|
||||
[sparseDirectories]
|
||||
)
|
||||
// Why: a preset attribution should only ride along if what's about to be
|
||||
// created actually equals the saved preset. If the user picked a preset and
|
||||
// then edited the textarea, we want the worktree to be a "Custom" sparse
|
||||
// checkout — not falsely tagged as the original preset.
|
||||
const effectivePresetId = useMemo(() => {
|
||||
if (!sparseSelectedPresetId) {
|
||||
return null
|
||||
}
|
||||
const selected = sparsePresets.find((preset) => preset.id === sparseSelectedPresetId)
|
||||
if (!selected) {
|
||||
return null
|
||||
}
|
||||
return sparseDirectoriesMatch(selected.directories, normalizedSparseDirectories)
|
||||
? selected.id
|
||||
: null
|
||||
}, [normalizedSparseDirectories, sparsePresets, sparseSelectedPresetId])
|
||||
|
||||
const sparseError = useMemo(() => {
|
||||
if (!sparseEnabled) {
|
||||
return null
|
||||
}
|
||||
if (selectedRepo?.connectionId) {
|
||||
return 'Sparse checkout is only supported for local repos right now.'
|
||||
}
|
||||
if (normalizedSparseDirectories.length === 0) {
|
||||
return 'Enter at least one repo-relative directory.'
|
||||
}
|
||||
if (
|
||||
normalizedSparseDirectories.some((entry) => entry === '.' || entry.split('/').includes('..'))
|
||||
) {
|
||||
return 'Use repo-relative directories, not root or parent paths.'
|
||||
}
|
||||
return null
|
||||
}, [normalizedSparseDirectories, selectedRepo?.connectionId, sparseEnabled])
|
||||
const parsedLinkedIssueNumber = useMemo(
|
||||
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
|
||||
[linkedIssue]
|
||||
|
|
@ -483,6 +538,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}
|
||||
}, [eligibleRepos, repoId, setRepoId])
|
||||
|
||||
// Why: the compact sparse dropdown is always visible under Advanced, so
|
||||
// presets must load before sparse mode is enabled.
|
||||
useEffect(() => {
|
||||
if (!repoId || selectedRepo?.connectionId) {
|
||||
return
|
||||
}
|
||||
if (sparsePresetsByRepo[repoId] !== undefined) {
|
||||
return
|
||||
}
|
||||
void fetchSparsePresets(repoId)
|
||||
}, [fetchSparsePresets, repoId, selectedRepo?.connectionId, sparsePresetsByRepo])
|
||||
|
||||
// Why: detect agents for the selected repo. For local repos this runs once
|
||||
// on mount (deduped by the store). For remote repos it re-runs when the
|
||||
// selected repo changes so the agent list matches the SSH host.
|
||||
|
|
@ -920,6 +987,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setLinkedIssue('')
|
||||
setLinkedPR(null)
|
||||
setLinkedWorkItem(null)
|
||||
setSparseEnabled(false)
|
||||
setSparseDirectories('')
|
||||
// Why: presets are repo-scoped, so a stale selection from the prior
|
||||
// repo would be meaningless after a repo switch.
|
||||
setSparseSelectedPresetId(null)
|
||||
// Why: the Start-from picker is repo-scoped, so any prior branch/PR
|
||||
// selection is meaningless in the new repo. Resetting to undefined
|
||||
// makes the field fall back to the new repo's effective base ref.
|
||||
|
|
@ -929,6 +1001,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
[baseBranch, linkedWorkItem, repoId, setRepoId]
|
||||
)
|
||||
|
||||
const handleSparseSelectPreset = useCallback((preset: SparsePreset | null): void => {
|
||||
if (preset) {
|
||||
setSparseEnabled(true)
|
||||
setSparseDirectories(preset.directories.join('\n'))
|
||||
setSparseSelectedPresetId(preset.id)
|
||||
} else {
|
||||
setSparseEnabled(false)
|
||||
setSparseDirectories('')
|
||||
setSparseSelectedPresetId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
|
||||
setBaseBranch(next)
|
||||
setStartFromResetHint(null)
|
||||
|
|
@ -993,7 +1077,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
!selectedRepo ||
|
||||
shouldWaitForSetupCheck ||
|
||||
shouldWaitForIssueAutomationCheck ||
|
||||
(requiresExplicitSetupChoice && !setupDecision)
|
||||
(requiresExplicitSetupChoice && !setupDecision) ||
|
||||
sparseError !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -1015,7 +1100,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
|
||||
}
|
||||
|
||||
const result = await createWorktree(repoId, workspaceName, baseBranch, effectiveSetupDecision)
|
||||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
baseBranch,
|
||||
effectiveSetupDecision,
|
||||
sparseEnabled
|
||||
? {
|
||||
directories: normalizedSparseDirectories,
|
||||
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
||||
await applyWorktreeMeta(worktree.id, {
|
||||
|
|
@ -1075,6 +1171,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
issueCommandTemplate,
|
||||
linkedPR,
|
||||
linkedWorkItem?.url,
|
||||
normalizedSparseDirectories,
|
||||
note,
|
||||
onCreated,
|
||||
parsedLinkedIssueNumber,
|
||||
|
|
@ -1089,6 +1186,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setRightSidebarTab,
|
||||
setSidebarOpen,
|
||||
setupDecision,
|
||||
sparseEnabled,
|
||||
sparseError,
|
||||
effectivePresetId,
|
||||
tuiAgent,
|
||||
shouldRunIssueAutomation,
|
||||
shouldWaitForIssueAutomationCheck,
|
||||
|
|
@ -1111,7 +1211,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
!workspaceName ||
|
||||
!selectedRepo ||
|
||||
shouldWaitForSetupCheck ||
|
||||
(requiresExplicitSetupChoice && !setupDecision)
|
||||
(requiresExplicitSetupChoice && !setupDecision) ||
|
||||
sparseError !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -1129,7 +1230,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
repoId,
|
||||
workspaceName,
|
||||
baseBranch,
|
||||
effectiveSetupDecision
|
||||
effectiveSetupDecision,
|
||||
sparseEnabled
|
||||
? {
|
||||
directories: normalizedSparseDirectories,
|
||||
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
||||
|
|
@ -1181,6 +1288,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
createWorktree,
|
||||
fallbackCreatureName,
|
||||
name,
|
||||
normalizedSparseDirectories,
|
||||
note,
|
||||
onCreated,
|
||||
persistDraft,
|
||||
|
|
@ -1194,6 +1302,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setRightSidebarTab,
|
||||
setSidebarOpen,
|
||||
setupDecision,
|
||||
sparseEnabled,
|
||||
sparseError,
|
||||
effectivePresetId,
|
||||
shouldWaitForSetupCheck
|
||||
]
|
||||
)
|
||||
|
|
@ -1204,7 +1315,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
creating ||
|
||||
shouldWaitForSetupCheck ||
|
||||
shouldWaitForIssueAutomationCheck ||
|
||||
(requiresExplicitSetupChoice && !setupDecision)
|
||||
(requiresExplicitSetupChoice && !setupDecision) ||
|
||||
sparseError !== null
|
||||
|
||||
const cardProps: ComposerCardProps = {
|
||||
eligibleRepos,
|
||||
|
|
@ -1258,7 +1370,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
onSetupDecisionChange: setSetupDecision,
|
||||
shouldWaitForSetupCheck,
|
||||
resolvedSetupDecision,
|
||||
createError
|
||||
createError,
|
||||
canUseSparseCheckout: !selectedRepo?.connectionId,
|
||||
sparsePresets,
|
||||
sparseSelectedPresetId,
|
||||
onSparseSelectPreset: handleSparseSelectPreset
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
|
||||
|
||||
export function isAbsoluteSparseDirectoryPath(value: string): boolean {
|
||||
const entry = value.trim()
|
||||
return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
|
||||
}
|
||||
|
||||
/** Normalize the user's free-form textarea input into a clean directory list:
|
||||
* trim whitespace, convert backslashes to forward slashes, strip leading and
|
||||
* trailing slashes, drop empty lines, dedupe. Order preserved so the
|
||||
* textarea round-trips with the user's typing intent intact. */
|
||||
export function normalizeSparseDirectoryLines(value: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
return value
|
||||
.split('\n')
|
||||
.map((entry) =>
|
||||
entry
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
)
|
||||
.filter((entry) => entry.length > 0)
|
||||
.filter((entry) => {
|
||||
if (seen.has(entry)) {
|
||||
return false
|
||||
}
|
||||
seen.add(entry)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Order-independent set comparison so "shared/ui\npackages/web" matches
|
||||
* "packages/web\nshared/ui" — used by the composer to decide whether the
|
||||
* current textarea content matches a saved preset. */
|
||||
export function sparseDirectoriesMatch(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
const set = new Set(left)
|
||||
return right.every((entry) => set.has(entry))
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSparsePresetDirectories } from './sparse-preset-draft'
|
||||
|
||||
describe('parseSparsePresetDirectories', () => {
|
||||
it('normalizes textarea input into unique repo-relative directories', () => {
|
||||
expect(
|
||||
parseSparsePresetDirectories(`
|
||||
src\\renderer
|
||||
packages/ui/
|
||||
src/renderer
|
||||
`)
|
||||
).toEqual({
|
||||
directories: ['src/renderer', 'packages/ui'],
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('requires at least one directory', () => {
|
||||
expect(parseSparsePresetDirectories(' \n ')).toEqual({
|
||||
directories: [],
|
||||
error: 'Add at least one directory.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects root and parent path entries', () => {
|
||||
expect(parseSparsePresetDirectories('.')).toEqual({
|
||||
directories: [],
|
||||
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
|
||||
})
|
||||
expect(parseSparsePresetDirectories('src/../packages')).toEqual({
|
||||
directories: [],
|
||||
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
|
||||
})
|
||||
expect(parseSparsePresetDirectories('/')).toEqual({
|
||||
directories: [],
|
||||
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
|
||||
'rejects absolute directory input before normalization: %s',
|
||||
(entry) => {
|
||||
expect(parseSparsePresetDirectories(entry)).toEqual({
|
||||
directories: [],
|
||||
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { isAbsoluteSparseDirectoryPath, normalizeSparseDirectoryLines } from '@/lib/sparse-paths'
|
||||
|
||||
export type SparsePresetDirectoryParseResult = {
|
||||
directories: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function parseSparsePresetDirectories(value: string): SparsePresetDirectoryParseResult {
|
||||
const rawEntries = value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
|
||||
// Why: absolute paths can look repo-relative after slash normalization.
|
||||
if (rawEntries.some(isAbsoluteSparseDirectoryPath)) {
|
||||
return {
|
||||
directories: [],
|
||||
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
|
||||
}
|
||||
}
|
||||
|
||||
const directories = normalizeSparseDirectoryLines(value)
|
||||
|
||||
if (directories.length === 0) {
|
||||
return {
|
||||
directories,
|
||||
error: 'Add at least one directory.'
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.some((entry) => entry === '.' || entry.split('/').includes('..'))) {
|
||||
return {
|
||||
directories: [],
|
||||
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
directories,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { create } from 'zustand'
|
||||
import type { AppState } from './types'
|
||||
import { createRepoSlice } from './slices/repos'
|
||||
import { createSparsePresetsSlice } from './slices/sparse-presets'
|
||||
import { createWorktreeSlice } from './slices/worktrees'
|
||||
import { createTerminalSlice } from './slices/terminals'
|
||||
import { createTabsSlice } from './slices/tabs'
|
||||
|
|
@ -25,6 +26,7 @@ import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
|
|||
|
||||
export const useAppStore = create<AppState>()((...a) => ({
|
||||
...createRepoSlice(...a),
|
||||
...createSparsePresetsSlice(...a),
|
||||
...createWorktreeSlice(...a),
|
||||
...createTerminalSlice(...a),
|
||||
...createTabsSlice(...a),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
import { create } from 'zustand'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SparsePreset } from '../../../../shared/types'
|
||||
import type { AppState } from '../types'
|
||||
import { createSparsePresetsSlice } from './sparse-presets'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const mockApi = {
|
||||
sparsePresets: {
|
||||
list: vi.fn(),
|
||||
save: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error -- test shim
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
function createTestStore() {
|
||||
return create<AppState>()((...a) => ({ ...createSparsePresetsSlice(...a) }) as AppState)
|
||||
}
|
||||
|
||||
function makePreset(
|
||||
overrides: Partial<SparsePreset> & { id: string; repoId: string }
|
||||
): SparsePreset {
|
||||
return {
|
||||
name: overrides.id,
|
||||
directories: ['packages/app'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSparsePresetsSlice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApi.sparsePresets.list.mockResolvedValue([])
|
||||
mockApi.sparsePresets.save.mockImplementation((args: Partial<SparsePreset>) =>
|
||||
Promise.resolve(
|
||||
makePreset({
|
||||
id: args.id ?? `preset-${args.name}`,
|
||||
repoId: args.repoId ?? 'repo-1',
|
||||
name: args.name ?? 'Preset',
|
||||
directories: args.directories ?? ['packages/app'],
|
||||
updatedAt: 2
|
||||
})
|
||||
)
|
||||
)
|
||||
mockApi.sparsePresets.remove.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('fetches presets into the requested repo bucket', async () => {
|
||||
const store = createTestStore()
|
||||
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
|
||||
mockApi.sparsePresets.list.mockResolvedValueOnce([preset])
|
||||
|
||||
await store.getState().fetchSparsePresets('repo-1')
|
||||
|
||||
expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
|
||||
expect(store.getState().sparsePresetsByRepo).toEqual({ 'repo-1': [preset] })
|
||||
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
|
||||
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an unfetched repo bucket missing while presets are loading', async () => {
|
||||
const store = createTestStore()
|
||||
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
|
||||
let resolveList: (presets: SparsePreset[]) => void = () => {}
|
||||
mockApi.sparsePresets.list.mockReturnValueOnce(
|
||||
new Promise<SparsePreset[]>((resolve) => {
|
||||
resolveList = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const fetchPromise = store.getState().fetchSparsePresets('repo-1')
|
||||
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
|
||||
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(true)
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loading')
|
||||
|
||||
resolveList([preset])
|
||||
await fetchPromise
|
||||
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
|
||||
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
|
||||
})
|
||||
|
||||
it('does not refetch while a repo bucket is loading or already loaded', async () => {
|
||||
const store = createTestStore()
|
||||
let resolveList: (presets: SparsePreset[]) => void = () => {}
|
||||
mockApi.sparsePresets.list.mockReturnValueOnce(
|
||||
new Promise<SparsePreset[]>((resolve) => {
|
||||
resolveList = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const fetchPromise = store.getState().fetchSparsePresets('repo-1')
|
||||
await store.getState().fetchSparsePresets('repo-1')
|
||||
|
||||
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveList([])
|
||||
await fetchPromise
|
||||
await store.getState().fetchSparsePresets('repo-1')
|
||||
|
||||
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears loading state without marking the repo loaded when fetch fails', async () => {
|
||||
const store = createTestStore()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
|
||||
|
||||
try {
|
||||
await store.getState().fetchSparsePresets('repo-1')
|
||||
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
|
||||
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
|
||||
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBe('disk failed')
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears a failed fetch status when retrying presets succeeds', async () => {
|
||||
const store = createTestStore()
|
||||
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockApi.sparsePresets.list
|
||||
.mockRejectedValueOnce(new Error('disk failed'))
|
||||
.mockResolvedValueOnce([preset])
|
||||
|
||||
try {
|
||||
await store.getState().fetchSparsePresets('repo-1')
|
||||
await store.getState().fetchSparsePresets('repo-1')
|
||||
|
||||
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
|
||||
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('saves presets per repo and sorts the repo list by name', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
sparsePresetsByRepo: {
|
||||
'repo-1': [makePreset({ id: 'z', repoId: 'repo-1', name: 'Zed' })],
|
||||
'repo-2': [makePreset({ id: 'other', repoId: 'repo-2', name: 'Other' })]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const saved = await store.getState().saveSparsePreset({
|
||||
repoId: 'repo-1',
|
||||
name: 'Api',
|
||||
directories: ['packages/api']
|
||||
})
|
||||
|
||||
expect(saved?.name).toBe('Api')
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
|
||||
'Api',
|
||||
'Zed'
|
||||
])
|
||||
expect(store.getState().sparsePresetsByRepo['repo-2'].map((preset) => preset.name)).toEqual([
|
||||
'Other'
|
||||
])
|
||||
})
|
||||
|
||||
it('loads an unfetched repo bucket before saving so existing presets stay visible', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makePreset({ id: 'existing', repoId: 'repo-1', name: 'Existing' })
|
||||
mockApi.sparsePresets.list.mockResolvedValueOnce([existing])
|
||||
|
||||
const saved = await store.getState().saveSparsePreset({
|
||||
repoId: 'repo-1',
|
||||
name: 'Api',
|
||||
directories: ['packages/api']
|
||||
})
|
||||
|
||||
expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
|
||||
expect(mockApi.sparsePresets.save).toHaveBeenCalledTimes(1)
|
||||
expect(saved?.name).toBe('Api')
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
|
||||
'Api',
|
||||
'Existing'
|
||||
])
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
|
||||
})
|
||||
|
||||
it('does not save or synthesize a repo bucket when presets fail to load first', async () => {
|
||||
const store = createTestStore()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
|
||||
|
||||
try {
|
||||
const saved = await store.getState().saveSparsePreset({
|
||||
repoId: 'repo-1',
|
||||
name: 'Api',
|
||||
directories: ['packages/api']
|
||||
})
|
||||
|
||||
expect(saved).toBeNull()
|
||||
expect(mockApi.sparsePresets.save).not.toHaveBeenCalled()
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
|
||||
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous repo presets when remove fails', async () => {
|
||||
const store = createTestStore()
|
||||
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
|
||||
mockApi.sparsePresets.remove.mockRejectedValueOnce(new Error('disk failed'))
|
||||
store.setState({ sparsePresetsByRepo: { 'repo-1': [preset] } } as Partial<AppState>)
|
||||
|
||||
await store.getState().removeSparsePreset({ repoId: 'repo-1', presetId: 'preset-1' })
|
||||
|
||||
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import type { StateCreator } from 'zustand'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppState } from '../types'
|
||||
import type { SparsePreset } from '../../../../shared/types'
|
||||
|
||||
const ERROR_TOAST_DURATION = 60_000
|
||||
|
||||
export type SparsePresetsLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'
|
||||
|
||||
export type SparsePresetsSlice = {
|
||||
/** Per-repo preset list. Lazily populated by `fetchSparsePresets`; missing
|
||||
* key means "not yet fetched", empty array means "fetched, none exist". */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
/** Per-repo fetch guard so missing preset buckets keep their loading meaning. */
|
||||
sparsePresetsLoadingByRepo: Record<string, boolean>
|
||||
sparsePresetsLoadStatusByRepo: Record<string, SparsePresetsLoadStatus>
|
||||
sparsePresetsErrorByRepo: Record<string, string | undefined>
|
||||
fetchSparsePresets: (repoId: string) => Promise<void>
|
||||
saveSparsePreset: (args: {
|
||||
repoId: string
|
||||
id?: string
|
||||
name: string
|
||||
directories: string[]
|
||||
}) => Promise<SparsePreset | null>
|
||||
removeSparsePreset: (args: { repoId: string; presetId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export const createSparsePresetsSlice: StateCreator<AppState, [], [], SparsePresetsSlice> = (
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
sparsePresetsByRepo: {},
|
||||
sparsePresetsLoadingByRepo: {},
|
||||
sparsePresetsLoadStatusByRepo: {},
|
||||
sparsePresetsErrorByRepo: {},
|
||||
|
||||
fetchSparsePresets: async (repoId) => {
|
||||
const state = get()
|
||||
if (
|
||||
state.sparsePresetsByRepo[repoId] !== undefined ||
|
||||
state.sparsePresetsLoadingByRepo[repoId]
|
||||
) {
|
||||
return
|
||||
}
|
||||
set((s) => ({
|
||||
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: true },
|
||||
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loading' },
|
||||
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
|
||||
}))
|
||||
try {
|
||||
const presets = await window.api.sparsePresets.list({ repoId })
|
||||
set((s) => ({
|
||||
sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: presets },
|
||||
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
|
||||
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loaded' },
|
||||
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
|
||||
}))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
set((s) => ({
|
||||
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
|
||||
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'error' },
|
||||
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: message }
|
||||
}))
|
||||
console.error(`Failed to fetch sparse presets for repo ${repoId}:`, err)
|
||||
}
|
||||
},
|
||||
|
||||
saveSparsePreset: async (args) => {
|
||||
try {
|
||||
if (get().sparsePresetsByRepo[args.repoId] === undefined) {
|
||||
// Why: a saved preset alone is not an authoritative repo bucket; load
|
||||
// existing presets first so we do not hide them behind a one-item cache.
|
||||
await get().fetchSparsePresets(args.repoId)
|
||||
if (get().sparsePresetsByRepo[args.repoId] === undefined) {
|
||||
toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
|
||||
description: 'Presets must load before saving.',
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
const saved = await window.api.sparsePresets.save(args)
|
||||
set((s) => {
|
||||
const existing = s.sparsePresetsByRepo[args.repoId]
|
||||
if (existing === undefined) {
|
||||
return {}
|
||||
}
|
||||
const without = existing.filter((preset) => preset.id !== saved.id)
|
||||
return {
|
||||
sparsePresetsByRepo: {
|
||||
...s.sparsePresetsByRepo,
|
||||
[args.repoId]: [...without, saved].sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
toast.success(args.id ? 'Preset updated' : 'Preset saved', { description: saved.name })
|
||||
return saved
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
removeSparsePreset: async ({ repoId, presetId }) => {
|
||||
const previous = get().sparsePresetsByRepo[repoId] ?? []
|
||||
// Why: optimistic local update keeps the popover responsive — toast handles
|
||||
// the failure path by restoring state.
|
||||
set((s) => ({
|
||||
sparsePresetsByRepo: {
|
||||
...s.sparsePresetsByRepo,
|
||||
[repoId]: previous.filter((preset) => preset.id !== presetId)
|
||||
}
|
||||
}))
|
||||
try {
|
||||
await window.api.sparsePresets.remove({ repoId, presetId })
|
||||
toast.success('Preset removed')
|
||||
} catch (err) {
|
||||
set((s) => ({
|
||||
sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: previous }
|
||||
}))
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error('Failed to remove preset', {
|
||||
description: message,
|
||||
duration: ERROR_TOAST_DURATION
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -90,6 +90,7 @@ const mockApi = {
|
|||
globalThis.window = { api: mockApi }
|
||||
|
||||
import { createRepoSlice } from './repos'
|
||||
import { createSparsePresetsSlice } from './sparse-presets'
|
||||
import { createWorktreeSlice } from './worktrees'
|
||||
import { createTerminalSlice } from './terminals'
|
||||
import { createTabsSlice } from './tabs'
|
||||
|
|
@ -113,6 +114,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history'
|
|||
function createTestStore() {
|
||||
return create<AppState>()((...a) => ({
|
||||
...createRepoSlice(...a),
|
||||
...createSparsePresetsSlice(...a),
|
||||
...createWorktreeSlice(...a),
|
||||
...createTerminalSlice(...a),
|
||||
...createTabsSlice(...a),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from '../../../../shared/types'
|
||||
import type { OpenFile } from './editor'
|
||||
import { createRepoSlice } from './repos'
|
||||
import { createSparsePresetsSlice } from './sparse-presets'
|
||||
import { createWorktreeSlice } from './worktrees'
|
||||
import { createTerminalSlice } from './terminals'
|
||||
import { createTabsSlice } from './tabs'
|
||||
|
|
@ -40,6 +41,7 @@ export const TEST_REPO = {
|
|||
export function createTestStore() {
|
||||
return create<AppState>()((...a) => ({
|
||||
...createRepoSlice(...a),
|
||||
...createSparsePresetsSlice(...a),
|
||||
...createWorktreeSlice(...a),
|
||||
...createTerminalSlice(...a),
|
||||
...createTabsSlice(...a),
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ const mockApi = {
|
|||
globalThis.window = { api: mockApi }
|
||||
|
||||
import { createRepoSlice } from './repos'
|
||||
import { createSparsePresetsSlice } from './sparse-presets'
|
||||
import { createWorktreeSlice } from './worktrees'
|
||||
import { createTerminalSlice } from './terminals'
|
||||
import { createTabsSlice } from './tabs'
|
||||
|
|
@ -110,6 +111,7 @@ const WT = 'repo1::/tmp/feature'
|
|||
function createTestStore() {
|
||||
return create<AppState>()((...a) => ({
|
||||
...createRepoSlice(...a),
|
||||
...createSparsePresetsSlice(...a),
|
||||
...createWorktreeSlice(...a),
|
||||
...createTerminalSlice(...a),
|
||||
...createTabsSlice(...a),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
CreateWorktreeResult,
|
||||
CreateSparseCheckoutRequest,
|
||||
SetupDecision,
|
||||
Worktree,
|
||||
WorktreeMeta
|
||||
|
|
@ -40,7 +41,8 @@ export type WorktreeSlice = {
|
|||
repoId: string,
|
||||
name: string,
|
||||
baseBranch?: string,
|
||||
setupDecision?: SetupDecision
|
||||
setupDecision?: SetupDecision,
|
||||
sparseCheckout?: CreateSparseCheckoutRequest
|
||||
) => Promise<CreateWorktreeResult>
|
||||
removeWorktree: (
|
||||
worktreeId: string,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import {
|
|||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
|
||||
|
||||
function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (!a || !b || a.length !== b.length) {
|
||||
return !a?.length && !b?.length
|
||||
}
|
||||
return a.every((v, i) => v === b[i])
|
||||
}
|
||||
|
||||
function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean {
|
||||
if (!current || current.length !== next.length) {
|
||||
return false
|
||||
|
|
@ -26,6 +36,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
|
|||
worktree.branch === candidate.branch &&
|
||||
worktree.isBare === candidate.isBare &&
|
||||
worktree.isMainWorktree === candidate.isMainWorktree &&
|
||||
worktree.isSparse === candidate.isSparse &&
|
||||
worktree.displayName === candidate.displayName &&
|
||||
worktree.comment === candidate.comment &&
|
||||
worktree.linkedIssue === candidate.linkedIssue &&
|
||||
|
|
@ -34,7 +45,9 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
|
|||
worktree.isUnread === candidate.isUnread &&
|
||||
worktree.isPinned === candidate.isPinned &&
|
||||
worktree.sortOrder === candidate.sortOrder &&
|
||||
worktree.lastActivityAt === candidate.lastActivityAt
|
||||
worktree.lastActivityAt === candidate.lastActivityAt &&
|
||||
worktree.sparseBaseRef === candidate.sparseBaseRef &&
|
||||
arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -86,7 +99,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
await Promise.all(repos.map((r) => get().fetchWorktrees(r.id)))
|
||||
},
|
||||
|
||||
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit') => {
|
||||
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit', sparseCheckout) => {
|
||||
const retryableConflictPatterns = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
|
|
@ -103,7 +116,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
repoId,
|
||||
name: candidateName,
|
||||
baseBranch,
|
||||
setupDecision
|
||||
setupDecision,
|
||||
sparseCheckout
|
||||
})
|
||||
// Why: a file watcher (worktrees.onChanged) can fire between the
|
||||
// backend creating the worktree and this callback running, causing
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RepoSlice } from './slices/repos'
|
||||
import type { SparsePresetsSlice } from './slices/sparse-presets'
|
||||
import type { WorktreeSlice } from './slices/worktrees'
|
||||
import type { TerminalSlice } from './slices/terminals'
|
||||
import type { TabsSlice } from './slices/tabs'
|
||||
|
|
@ -20,6 +21,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents'
|
|||
import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
|
||||
|
||||
export type AppState = RepoSlice &
|
||||
SparsePresetsSlice &
|
||||
WorktreeSlice &
|
||||
TerminalSlice &
|
||||
TabsSlice &
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
|||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
repos: [],
|
||||
sparsePresetsByRepo: {},
|
||||
worktreeMeta: {},
|
||||
settings: getDefaultSettings(homedir),
|
||||
ui: getDefaultUIState(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export type GitWorktreeInfo = {
|
|||
head: string
|
||||
branch: string
|
||||
isBare: boolean
|
||||
isSparse?: boolean
|
||||
/** True for the repo's main working tree (the first entry from `git worktree list`).
|
||||
* Linked worktrees created via `git worktree add` have this set to false. */
|
||||
isMainWorktree: boolean
|
||||
|
|
@ -67,6 +68,11 @@ export type Worktree = {
|
|||
isPinned: boolean
|
||||
sortOrder: number
|
||||
lastActivityAt: number
|
||||
sparseDirectories?: string[]
|
||||
sparseBaseRef?: string
|
||||
/** ID of the saved preset this worktree was created from, if any. Cleared
|
||||
* when the worktree is no longer sparse on refresh. */
|
||||
sparsePresetId?: string
|
||||
diffComments?: DiffComment[]
|
||||
} & GitWorktreeInfo
|
||||
|
||||
|
|
@ -82,6 +88,9 @@ export type WorktreeMeta = {
|
|||
isPinned: boolean
|
||||
sortOrder: number
|
||||
lastActivityAt: number
|
||||
sparseDirectories?: string[]
|
||||
sparseBaseRef?: string
|
||||
sparsePresetId?: string
|
||||
diffComments?: DiffComment[]
|
||||
}
|
||||
|
||||
|
|
@ -690,11 +699,32 @@ export type WorktreeSetupLaunch = {
|
|||
envVars: Record<string, string>
|
||||
}
|
||||
|
||||
export type CreateSparseCheckoutRequest = {
|
||||
directories: string[]
|
||||
/** Set when the directories came from a saved preset and the user did not
|
||||
* modify them — recorded on WorktreeMeta so the worktree can show "from
|
||||
* preset X" later. Cleared if the user edited the textarea. */
|
||||
presetId?: string
|
||||
}
|
||||
|
||||
/** A reusable per-repo sparse directory list. Saved by the user from the
|
||||
* composer; surfaced again the next time they create a worktree in the same
|
||||
* repo. The MVP scope (no preset) is `presetId === undefined`. */
|
||||
export type SparsePreset = {
|
||||
id: string
|
||||
repoId: string
|
||||
name: string
|
||||
directories: string[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type CreateWorktreeArgs = {
|
||||
repoId: string
|
||||
name: string
|
||||
baseBranch?: string
|
||||
setupDecision?: SetupDecision
|
||||
sparseCheckout?: CreateSparseCheckoutRequest
|
||||
}
|
||||
|
||||
export type CreateWorktreeResult = {
|
||||
|
|
@ -1184,6 +1214,9 @@ export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookR
|
|||
export type PersistedState = {
|
||||
schemaVersion: number
|
||||
repos: Repo[]
|
||||
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
|
||||
* presets are managed from the new-workspace composer and repo settings. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
worktreeMeta: Record<string, WorktreeMeta>
|
||||
settings: GlobalSettings
|
||||
ui: PersistedUIState
|
||||
|
|
|
|||
Loading…
Reference in New Issue