Add nested repo group imports (#2866)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-27 16:22:03 -07:00 committed by GitHub
parent 4581565e2b
commit 76ae675a01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 4639 additions and 496 deletions

View File

@ -1,9 +1,6 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import {
createReadStream,
createWriteStream,
existsSync,
mkdtempSync,
readdirSync,
@ -12,12 +9,9 @@ import {
rmSync,
writeFileSync
} from 'node:fs'
import { get as httpGet } from 'node:http'
import { get as httpsGet } from 'node:https'
import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { dirname, resolve } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
@ -25,12 +19,14 @@ const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
const { version: electronVersion } = electronRequire('./package.json')
const { downloadArtifact } = electronRequire('@electron/get')
const extract = electronRequire('extract-zip')
const platformPath = getElectronPlatformPath()
const MAX_DOWNLOAD_REDIRECTS = 5
main().catch((error) => {
console.error('[electron-package] Failed to install Electron package binary.')
console.error(error)
logElectronInstallDiagnostics()
process.exit(1)
})
@ -85,13 +81,18 @@ function repairElectronPathFile() {
async function installElectronPackageBinary() {
const electronDistDir = resolve(electronPackageDir, 'dist')
const artifactName = getElectronArtifactName()
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const zipPath = resolve(tempDir, artifactName)
try {
await downloadElectronArtifact(artifactName, zipPath)
await verifyElectronArtifactChecksum(artifactName, zipPath)
const zipPath = await downloadArtifact({
version: electronVersion,
artifactName: 'electron',
platform: process.env.npm_config_platform || osPlatform(),
arch: process.env.npm_config_arch || process.arch,
force: true,
tempDirectory: tempDir,
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
})
rmSync(electronDistDir, { recursive: true, force: true })
await extract(zipPath, { dir: electronDistDir })
@ -105,92 +106,11 @@ async function installElectronPackageBinary() {
}
}
async function downloadElectronArtifact(artifactName, zipPath) {
const artifactUrl = new URL(`v${electronVersion}/${artifactName}`, getElectronReleaseBaseUrl())
console.log(`[electron-package] Downloading ${artifactUrl}`)
await downloadUrlToFile(artifactUrl, zipPath, artifactName)
}
async function downloadUrlToFile(url, zipPath, artifactName, redirectCount = 0) {
const response = await requestUrl(url)
const status = response.statusCode ?? 0
if (status >= 300 && status < 400 && response.headers.location) {
response.resume()
if (redirectCount >= MAX_DOWNLOAD_REDIRECTS) {
throw new Error(`Failed to download ${artifactName}: too many redirects`)
}
const nextUrl = new URL(response.headers.location, url)
console.log(`[electron-package] Following redirect to ${nextUrl.origin}${nextUrl.pathname}`)
await downloadUrlToFile(nextUrl, zipPath, artifactName, redirectCount + 1)
return
}
if (status < 200 || status >= 300) {
response.resume()
throw new Error(
`Failed to download ${artifactName}: ${status} ${response.statusMessage ?? ''}`.trim()
)
}
await pipeline(response, createWriteStream(zipPath))
}
function requestUrl(url) {
const get = url.protocol === 'http:' ? httpGet : url.protocol === 'https:' ? httpsGet : undefined
if (!get) {
throw new Error(`Unsupported Electron download protocol: ${url.protocol}`)
}
return new Promise((resolve, reject) => {
const request = get(
url,
{
headers: {
'user-agent': 'orca-electron-package-installer'
}
},
resolve
)
request.on('error', reject)
})
}
async function verifyElectronArtifactChecksum(artifactName, zipPath) {
if (
function shouldUseRemoteChecksums() {
return Boolean(
process.env.electron_use_remote_checksums ||
process.env.npm_config_electron_use_remote_checksums
) {
return
}
const expected = electronRequire('./checksums.json')[artifactName]
if (!expected) {
throw new Error(`Missing Electron checksum for ${artifactName}`)
}
const hash = createHash('sha256')
for await (const chunk of createReadStream(zipPath)) {
hash.update(chunk)
}
const actual = hash.digest('hex')
if (actual !== expected) {
throw new Error(`Checksum mismatch for ${artifactName}: expected ${expected}, got ${actual}`)
}
}
function getElectronArtifactName() {
return `electron-v${electronVersion}-${process.env.npm_config_platform || osPlatform()}-${
process.env.npm_config_arch || process.arch
}.zip`
}
function getElectronReleaseBaseUrl() {
const configuredMirror = process.env.ELECTRON_MIRROR || process.env.npm_config_electron_mirror
const baseUrl = configuredMirror || 'https://github.com/electron/electron/releases/download/'
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
process.env.npm_config_electron_use_remote_checksums
)
}
function logElectronInstallDiagnostics() {

View File

@ -94,7 +94,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
// destructive or read/write operation, so the security boundary remains
// enforced where it matters.
const repos = getLocalRepos(store)
const perRepoResults = await Promise.all(
const perProjectResults = await Promise.all(
repos.map(async (repo) => {
const roots: string[] = []
try {
@ -117,7 +117,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
registeredWorktreeRoots.clear()
registeredWorktreeRootsByRepo.clear()
registeredWorktreeRootRepoIds.clear()
for (const { repoId, roots } of perRepoResults) {
for (const { repoId, roots } of perProjectResults) {
const normalizedRoots = new Set<string>()
for (const root of roots) {
normalizedRoots.add(root)

View File

@ -28,7 +28,7 @@ const {
mockStore: {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeRepo: vi.fn(),
removeProject: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn()
},

View File

@ -8,29 +8,42 @@ import { EventEmitter } from 'events'
import type * as RepoModule from '../git/repo'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
const { handleMock, mockStore, mockGitProvider, mockMultiplexer, gitSpawnMock } = vi.hoisted(
() => ({
handleMock: vi.fn(),
mockStore: {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeRepo: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn(),
getSshTarget: vi.fn()
},
mockGitProvider: {
isGitRepo: vi.fn().mockReturnValue(true),
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
},
mockMultiplexer: {
request: vi.fn(),
notify: vi.fn()
},
gitSpawnMock: vi.fn()
})
)
const {
handleMock,
mockStore,
mockGitProvider,
mockFilesystemProvider,
mockMultiplexer,
gitSpawnMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
mockStore: {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeProject: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn(),
getProjectGroups: vi.fn().mockReturnValue([]),
createProjectGroup: vi.fn(),
updateProjectGroup: vi.fn(),
deleteProjectGroup: vi.fn(),
moveProjectToGroup: vi.fn(),
getSshTarget: vi.fn()
},
mockGitProvider: {
isGitRepo: vi.fn().mockReturnValue(true),
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
},
mockFilesystemProvider: {
readDir: vi.fn().mockResolvedValue([])
},
mockMultiplexer: {
request: vi.fn(),
notify: vi.fn()
},
gitSpawnMock: vi.fn()
}))
vi.mock('electron', () => ({
dialog: { showOpenDialog: vi.fn() },
@ -76,6 +89,15 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
})
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
return mockFilesystemProvider
}
return undefined
})
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
@ -87,6 +109,164 @@ vi.mock('./ssh', () => ({
import { registerRepoHandlers } from './repos'
describe('projectGroups IPC validation', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mockWindow = {
isDestroyed: () => false,
webContents: { send: vi.fn() }
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
mockWindow.webContents.send.mockReset()
mockStore.createProjectGroup.mockReset()
mockStore.updateProjectGroup.mockReset()
mockStore.deleteProjectGroup.mockReset()
mockStore.moveProjectToGroup.mockReset()
mockStore.getRepos.mockReset()
mockStore.getRepos.mockReturnValue([])
mockFilesystemProvider.readDir.mockReset()
mockFilesystemProvider.readDir.mockResolvedValue([])
mockGitProvider.isGitRepoAsync.mockReset()
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
mockMultiplexer.notify.mockReset()
mockMultiplexer.request.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never)
})
it('rejects malformed local project group create arguments before persistence', () => {
expect(() =>
handlers.get('projectGroups:create')!(null, { name: 123, createdFrom: 'unexpected' })
).toThrow('invalid_project_group_create_args')
expect(mockStore.createProjectGroup).not.toHaveBeenCalled()
})
it('rejects malformed local project group update arguments before persistence', () => {
expect(() =>
handlers.get('projectGroups:update')!(null, {
groupId: 'group-1',
updates: { isCollapsed: 'yes' }
})
).toThrow('invalid_project_group_update_args')
expect(mockStore.updateProjectGroup).not.toHaveBeenCalled()
})
it('scans nested repositories over a connected SSH filesystem', async () => {
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
const result = await handlers.get('projectGroups:scanNested')!(null, {
path: '/srv/platform',
connectionId: 'conn-1'
})
expect(result).toMatchObject({
selectedPath: '/srv/platform',
selectedPathKind: 'non_git_folder',
repos: [{ path: '/srv/platform/api', displayName: 'api' }]
})
})
it('rejects local nested scans with relative paths', async () => {
await expect(
handlers.get('projectGroups:scanNested')!(null, {
path: 'relative/project'
})
).rejects.toThrow('Repo path must be an absolute path')
})
it('imports nested SSH repositories with connection-scoped repo entries', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
const result = await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: ['/srv/platform/api'],
connectionId: 'conn-1',
mode: 'group'
})
expect(result).toMatchObject({ importedCount: 1, failedCount: 0 })
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/srv/platform/api',
connectionId: 'conn-1',
projectGroupId: group.id
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: '/srv/platform/api'
})
})
it('sanitizes unexpected nested import errors before returning results', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
mockStore.createProjectGroup.mockReturnValue(group)
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
isRepo: path === '/srv/platform/api',
rootPath: null
}))
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
)
mockStore.addRepo.mockImplementationOnce(() => {
throw new Error('secret backend path /srv/platform/api')
})
const result = (await handlers.get('projectGroups:importNested')!(null, {
parentPath: '/srv/platform',
groupName: 'Platform',
projectPaths: ['/srv/platform/api'],
connectionId: 'conn-1',
mode: 'group'
})) as { projects: { error?: string }[] }
expect(result.projects[0].error).toBe('Repository could not be imported')
})
})
describe('repos:getGitUsername', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mockWindow = {
@ -171,6 +351,8 @@ describe('repos:addRemote', () => {
mockStore.addRepo.mockReset()
mockStore.getSshTarget.mockReset()
mockStore.updateRepo.mockReset()
mockGitProvider.isGitRepoAsync.mockReset()
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
mockMultiplexer.request.mockReset()
mockMultiplexer.notify.mockReset()
gitSpawnMock.mockReset()

View File

@ -8,7 +8,7 @@ const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({
mockStore: {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeRepo: vi.fn(),
removeProject: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn(),
getSparsePresets: vi.fn(),

View File

@ -4,10 +4,14 @@ boundary. Splitting by line count would scatter tightly coupled repo behavior. *
import type { BrowserWindow } from 'electron'
import { dialog, ipcMain } from 'electron'
import { randomUUID } from 'crypto'
import { z } from 'zod'
import type { Store } from '../persistence'
import type {
BaseRefSearchResult,
Repo,
ProjectGroup,
ProjectGroupImportResult,
NestedRepoScanResult,
BaseRefDefaultResult,
SparsePreset
} from '../../shared/types'
@ -19,7 +23,14 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
import { access, mkdir, readdir, rm } from 'fs/promises'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import { basename, isAbsolute, join } from 'path'
import { basename, isAbsolute, join, posix } from 'path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { getNextProjectGroupOrder } from '../../shared/project-groups'
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
import {
createNestedProjectGroupResolver,
resolveNestedRepoSelection
} from '../project-groups/nested-repo-import'
import {
isGitRepo,
getGitUsername,
@ -34,6 +45,7 @@ import {
searchBaseRefDetails
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitUsername } from '../git/git-username'
import { getActiveMultiplexer } from './ssh'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
@ -69,6 +81,132 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void {
let activeCloneProc: ChildProcess | null = null
let activeClonePath: string | null = null
const ProjectGroupCreateArgs = z.object({
name: z.string().min(1),
parentPath: z.string().nullable().optional(),
parentGroupId: z.string().nullable().optional(),
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
})
const ProjectGroupUpdateArgs = z.object({
groupId: z.string().min(1),
updates: z.object({
name: z.string().optional(),
isCollapsed: z.boolean().optional(),
tabOrder: z.number().finite().optional(),
color: z.string().nullable().optional()
})
})
const ProjectGroupSelectorArgs = z.object({
groupId: z.string().min(1)
})
const ProjectGroupMoveProjectArgs = z.object({
projectId: z.string().min(1),
groupId: z.string().nullable(),
order: z.number().finite().optional()
})
const ProjectGroupScanNestedArgs = z.object({
path: z.string().min(1),
connectionId: z.string().min(1).optional(),
options: z.unknown().optional()
})
const ProjectGroupImportNestedArgs = z.discriminatedUnion('mode', [
z.object({
parentPath: z.string().min(1),
groupName: z.string().min(1),
projectPaths: z.array(z.string()),
connectionId: z.string().min(1).optional(),
mode: z.literal('group')
}),
z.object({
parentPath: z.string().min(1),
groupName: z.string().optional().default(''),
projectPaths: z.array(z.string()),
connectionId: z.string().min(1).optional(),
mode: z.literal('separate')
})
])
function parseProjectGroupIpcArgs<T>(schema: z.ZodType<T>, value: unknown, errorCode: string): T {
const result = schema.safeParse(value)
if (result.success) {
return result.data
}
throw new Error(errorCode)
}
function validateNestedRepoScanRoot(path: string, connectionId?: string): void {
if (connectionId) {
return
}
if (!isAbsolute(path)) {
throw new Error('Repo path must be an absolute path')
}
}
function sanitizeNestedRepoImportError(context: string, error: unknown): string {
console.warn(`[project-groups] ${context}`, error)
return 'Repository could not be imported'
}
async function resolveSshProjectGroupPath(connectionId: string, path: string): Promise<string> {
if (path === '~' || path === '~/' || path.startsWith('~/')) {
const mux = getActiveMultiplexer(connectionId)
if (mux) {
try {
const result = (await mux.request('session.resolveHome', { path })) as {
resolvedPath: string
}
return result.resolvedPath
} catch {
return path
}
}
}
return path
}
async function scanNestedReposForIpc(args: {
path: string
connectionId?: string
options?: unknown
}): Promise<NestedRepoScanResult> {
validateNestedRepoScanRoot(args.path, args.connectionId)
if (!args.connectionId) {
return scanNestedRepos({ path: args.path, options: args.options })
}
const gitProvider = getSshGitProvider(args.connectionId)
const fsProvider = getSshFilesystemProvider(args.connectionId)
if (!gitProvider || !fsProvider) {
throw new Error('ssh_connection_unavailable')
}
const resolvedPath = await resolveSshProjectGroupPath(args.connectionId, args.path)
return scanNestedRepos({
path: resolvedPath,
options: args.options,
filesystem: {
readDirectory: async (dirPath) =>
(await fsProvider.readDir(dirPath)).map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory
})),
joinPath: (parentPath, childName) => posix.join(parentPath, childName),
basename: (path) => posix.basename(path),
isGitRepoPath: async (path) => {
try {
return (await gitProvider.isGitRepoAsync(path)).isRepo
} catch {
return false
}
}
}
})
}
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
@ -77,6 +215,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:remove')
ipcMain.removeHandler('repos:reorder')
ipcMain.removeHandler('repos:update')
ipcMain.removeHandler('projectGroups:list')
ipcMain.removeHandler('projectGroups:create')
ipcMain.removeHandler('projectGroups:update')
ipcMain.removeHandler('projectGroups:delete')
ipcMain.removeHandler('projectGroups:moveProject')
ipcMain.removeHandler('projectGroups:scanNested')
ipcMain.removeHandler('projectGroups:importNested')
ipcMain.removeHandler('repos:pickFolder')
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
@ -95,6 +240,190 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
return store.getRepos()
})
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
const args = parseProjectGroupIpcArgs(
ProjectGroupCreateArgs,
rawArgs,
'invalid_project_group_create_args'
)
const group = store.createProjectGroup({
name: args.name,
parentPath: args.parentPath ?? null,
parentGroupId: args.parentGroupId ?? null,
createdFrom: args.createdFrom ?? 'manual'
})
notifyReposChanged(mainWindow)
return group
})
ipcMain.handle('projectGroups:update', (_event, rawArgs: unknown): ProjectGroup | null => {
const args = parseProjectGroupIpcArgs(
ProjectGroupUpdateArgs,
rawArgs,
'invalid_project_group_update_args'
)
const updated = store.updateProjectGroup(args.groupId, args.updates)
if (updated) {
notifyReposChanged(mainWindow)
}
return updated
})
ipcMain.handle('projectGroups:delete', (_event, rawArgs: unknown): boolean => {
const args = parseProjectGroupIpcArgs(
ProjectGroupSelectorArgs,
rawArgs,
'invalid_project_group_delete_args'
)
const deleted = store.deleteProjectGroup(args.groupId)
if (deleted) {
notifyReposChanged(mainWindow)
}
return deleted
})
ipcMain.handle('projectGroups:moveProject', (_event, rawArgs: unknown): Repo | null => {
const args = parseProjectGroupIpcArgs(
ProjectGroupMoveProjectArgs,
rawArgs,
'invalid_project_group_move_repo_args'
)
const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order)
if (moved) {
notifyReposChanged(mainWindow)
}
return moved
})
ipcMain.handle(
'projectGroups:scanNested',
async (_event, rawArgs: unknown): Promise<NestedRepoScanResult> => {
const args = parseProjectGroupIpcArgs(
ProjectGroupScanNestedArgs,
rawArgs,
'invalid_project_group_scan_nested_args'
)
return scanNestedReposForIpc(args)
}
)
ipcMain.handle(
'projectGroups:importNested',
async (_event, rawArgs: unknown): Promise<ProjectGroupImportResult> => {
const args = parseProjectGroupIpcArgs(
ProjectGroupImportNestedArgs,
rawArgs,
'invalid_project_group_import_nested_args'
)
const requestedPaths = args.projectPaths
const scan = await scanNestedReposForIpc({
path: args.parentPath,
connectionId: args.connectionId
})
const selection = resolveNestedRepoSelection({ scan, projectPaths: requestedPaths })
const groupResolver = createNestedProjectGroupResolver({
parentPath: scan.selectedPath,
groupName: args.groupName ?? '',
mode: args.mode,
createGroup: (input) => store.createProjectGroup(input)
})
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
(repoPath) => ({
path: repoPath,
status: 'failed',
error: 'Repository was not found in the nested repo scan result'
})
)
for (const repoPath of selection.selectedPaths) {
try {
if (args.connectionId) {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider || !(await gitProvider.isGitRepoAsync(repoPath)).isRepo) {
results.push({
path: repoPath,
status: 'failed',
error: 'Not a valid git repository'
})
continue
}
} else if (!isGitRepo(repoPath)) {
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
continue
}
const existing = store
.getRepos()
.find(
(repo) =>
(repo.connectionId ?? null) === (args.connectionId ?? null) &&
normalizeRuntimePathForComparison(repo.path) ===
normalizeRuntimePathForComparison(repoPath)
)
const group = groupResolver.getGroupForRepo(repoPath)
if (existing) {
if (group) {
store.moveProjectToGroup(existing.id, group.id)
}
results.push({ path: repoPath, projectId: existing.id, status: 'already-known' })
continue
}
const repo: Repo = {
id: randomUUID(),
path: repoPath,
displayName: getRepoName(repoPath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
addedAt: Date.now(),
kind: 'git',
...(args.connectionId ? { connectionId: args.connectionId } : {}),
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
...(group
? {
projectGroupId: group.id,
projectGroupOrder: getNextProjectGroupOrder(store.getRepos(), group.id)
}
: {})
}
store.addRepo(repo)
if (args.connectionId) {
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
rootPath: repoPath
})
}
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
emitRepoAdded('folder_picker', false)
} catch (error) {
results.push({
path: repoPath,
status: 'failed',
error: sanitizeNestedRepoImportError('Failed to import nested repository', error)
})
}
}
const importedCount = results.filter((entry) => entry.status === 'imported').length
const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length
const failedCount = results.filter((entry) => entry.status === 'failed').length
if (importedCount + alreadyKnownCount === 0) {
for (const group of groupResolver.getCreatedGroups().reverse()) {
store.deleteProjectGroup(group.id)
}
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
const rootGroup = groupResolver.getRootGroup()
return {
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
projects: results,
importedCount,
alreadyKnownCount,
failedCount
}
}
)
ipcMain.handle(
'repos:add',
async (
@ -473,7 +802,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
store.removeRepo(args.repoId)
store.removeProject(args.repoId)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
})
@ -497,6 +826,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>

View File

@ -76,4 +76,27 @@ describe('registerRuntimeHandlers', () => {
_meta: { runtimeId: 'runtime-1' }
})
})
it('registers project group runtime RPC methods for local desktop callers', async () => {
const runtime = {
syncWindowGraph: vi.fn(),
getStatus: vi.fn(),
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
listProjectGroups: vi.fn().mockReturnValue([{ id: 'group-1', name: 'Platform' }])
}
registerRuntimeHandlers(runtime as never)
const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
expect(callRegistration).toBeTruthy()
const handler = callRegistration![1]
const result = await handler({ sender: {} }, { method: 'projectGroup.list' })
expect(result).toMatchObject({
ok: true,
result: { groups: [{ id: 'group-1', name: 'Platform' }] },
_meta: { runtimeId: 'runtime-1' }
})
})
})

View File

@ -1446,14 +1446,49 @@ describe('Store', () => {
expect(fetched!.gitUsername).toBe('testuser')
})
it('deleteProjectGroup ungroups repos from the deleted group subtree', async () => {
const store = await createStore()
const root = store.createProjectGroup({ name: 'Platform', createdFrom: 'folder-scan' })
const child = store.createProjectGroup({
name: 'Services',
parentGroupId: root.id,
createdFrom: 'folder-scan'
})
const sibling = store.createProjectGroup({ name: 'Tools', createdFrom: 'manual' })
store.addRepo(makeRepo({ id: 'direct', path: '/direct', projectGroupId: root.id }))
store.addRepo(makeRepo({ id: 'nested', path: '/nested', projectGroupId: child.id }))
store.addRepo(makeRepo({ id: 'sibling', path: '/sibling', projectGroupId: sibling.id }))
expect(store.deleteProjectGroup(root.id)).toBe(true)
expect(store.getProjectGroups().map((group) => group.id)).toEqual([sibling.id])
expect(store.getRepo('direct')?.projectGroupId).toBeNull()
expect(store.getRepo('nested')?.projectGroupId).toBeNull()
expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id)
})
it('sanitizes invalid project group updates before persisting a repo', async () => {
const store = await createStore()
const group = store.createProjectGroup({ name: 'Platform', createdFrom: 'manual' })
store.addRepo(makeRepo({ id: 'r1', projectGroupId: group.id, projectGroupOrder: 1 }))
const updated = store.updateRepo('r1', {
projectGroupId: '',
projectGroupOrder: Number.POSITIVE_INFINITY
} as never)
expect(updated?.projectGroupId).toBeNull()
expect(updated?.projectGroupOrder).toBe(1)
})
it('getRepo returns undefined for nonexistent id', async () => {
const store = await createStore()
expect(store.getRepo('nonexistent')).toBeUndefined()
})
// ── 6. removeRepo cleans up worktree meta ──────────────────────────
// ── 6. removeProject cleans up worktree meta ──────────────────────────
it('removeRepo deletes the repo and its worktree meta', async () => {
it('removeProject deletes the repo and its worktree meta', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1' }))
store.addRepo(makeRepo({ id: 'r2', path: '/repo2' }))
@ -1462,7 +1497,7 @@ describe('Store', () => {
store.setWorktreeMeta('r1::/path/wt2', { displayName: 'wt2' })
store.setWorktreeMeta('r2::/other', { displayName: 'other' })
store.removeRepo('r1')
store.removeProject('r1')
expect(store.getRepo('r1')).toBeUndefined()
expect(store.getWorktreeMeta('r1::/path/wt1')).toBeUndefined()
@ -1471,7 +1506,7 @@ describe('Store', () => {
expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other')
})
it('removeRepo deletes child and parent lineage for the repo', async () => {
it('removeProject deletes child and parent lineage for the repo', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1' }))
store.addRepo(makeRepo({ id: 'r2', path: '/repo2' }))
@ -1498,7 +1533,7 @@ describe('Store', () => {
})
)
store.removeRepo('r1')
store.removeProject('r1')
expect(store.getWorktreeLineage('r1::/path/child')).toBeUndefined()
expect(store.getWorktreeLineage('r2::/other-child')).toBeUndefined()
@ -4844,7 +4879,6 @@ describe('Store', () => {
const first = await createStore()
first.addRepo(makeRepo({ id: 'r1' }))
first.flush()
expect((readDataFile() as { repos: Repo[] }).repos[0].id).toBe('r1')
expect(readBackup(0).repos.map((r) => r.id)).toEqual(['r1'])
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))

View File

@ -33,6 +33,7 @@ import {
import type {
PersistedState,
Repo,
ProjectGroup,
SparsePreset,
WorktreeMeta,
WorktreeLineage,
@ -94,6 +95,14 @@ import {
} from '../shared/workspace-statuses'
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership'
import { sanitizeRepoIcon } from '../shared/repo-icon'
import {
clearMissingProjectGroupMemberships,
createProjectGroup,
getNextProjectGroupOrder,
getProjectGroupSubtreeIds,
normalizeProjectGroupName,
normalizeProjectGroups
} from '../shared/project-groups'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
normalizeRepoSourceControlAiOverrides,
@ -1525,6 +1534,7 @@ export class Store {
result = {
...defaults,
...parsed,
projectGroups: normalizeProjectGroups(parsed.projectGroups),
worktreeLineageById: parsed.worktreeLineageById ?? {},
settings: {
...defaults.settings,
@ -1819,6 +1829,7 @@ export class Store {
result = {
...result,
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
workspaceSession: pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
)
@ -2049,6 +2060,95 @@ export class Store {
return repo ? this.hydrateRepo(repo) : undefined
}
getProjectGroups(): ProjectGroup[] {
return [...(this.state.projectGroups ?? [])].sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
}
createProjectGroup(input: {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}): ProjectGroup {
const maxOrder = Math.max(
-1,
...(this.state.projectGroups ?? []).map((group) => group.tabOrder)
)
const group = createProjectGroup({
...input,
tabOrder: maxOrder + 1
})
this.state.projectGroups = [...(this.state.projectGroups ?? []), group]
this.scheduleSave()
return group
}
updateProjectGroup(
groupId: string,
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
): ProjectGroup | null {
const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId)
if (!group) {
return null
}
if (updates.name !== undefined) {
group.name = normalizeProjectGroupName(updates.name, group.name)
}
if (updates.isCollapsed !== undefined) {
group.isCollapsed = updates.isCollapsed
}
if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) {
group.tabOrder = updates.tabOrder
}
if (updates.color !== undefined) {
group.color = typeof updates.color === 'string' ? updates.color : null
}
group.updatedAt = Date.now()
this.scheduleSave()
return group
}
deleteProjectGroup(groupId: string): boolean {
const before = this.state.projectGroups?.length ?? 0
const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId)
this.state.projectGroups = (this.state.projectGroups ?? []).filter(
(group) => !deletedGroupIds.has(group.id)
)
if ((this.state.projectGroups?.length ?? 0) === before) {
return false
}
// Why: groups are sidebar organization only. Deleting one must not delete
// repos or worktrees, so contained repos from the full subtree are ungrouped.
this.state.repos = this.state.repos.map((repo) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
this.scheduleSave()
return true
}
moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null {
const repo = this.state.repos.find((entry) => entry.id === repoId)
if (!repo) {
return null
}
const normalizedGroupId =
groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId)
? groupId
: null
const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId)
repo.projectGroupId = normalizedGroupId
repo.projectGroupOrder =
typeof order === 'number' && Number.isFinite(order)
? order
: getNextProjectGroupOrder(siblingRepos, normalizedGroupId)
this.scheduleSave()
return this.hydrateRepo(repo)
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.scheduleSave()
@ -2086,7 +2186,7 @@ export class Store {
return true
}
removeRepo(id: string): void {
removeProject(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.
@ -2121,6 +2221,8 @@ export class Store {
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
@ -2130,6 +2232,23 @@ export class Store {
return null
}
const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates)
if ('projectGroupId' in sanitizedUpdates) {
const nextGroupId = sanitizedUpdates.projectGroupId
if (
typeof nextGroupId !== 'string' ||
nextGroupId.trim().length === 0 ||
!this.state.projectGroups.some((group) => group.id === nextGroupId)
) {
sanitizedUpdates.projectGroupId = null
}
}
if (
'projectGroupOrder' in sanitizedUpdates &&
(typeof sanitizedUpdates.projectGroupOrder !== 'number' ||
!Number.isFinite(sanitizedUpdates.projectGroupOrder))
) {
delete sanitizedUpdates.projectGroupOrder
}
const externalWorktreeVisibilityLegacy =
'externalWorktreeVisibility' in sanitizedUpdates &&
repo.externalWorktreeVisibilityLegacy === undefined

View File

@ -0,0 +1,79 @@
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, describe, expect, it } from 'vitest'
import { scanNestedRepos } from './nested-repo-discovery'
let tempDirs: string[] = []
async function tempRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'orca-nested-repos-'))
tempDirs.push(dir)
return dir
}
async function makeGitRepo(path: string): Promise<void> {
await mkdir(join(path, '.git'), { recursive: true })
}
afterEach(async () => {
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
tempDirs = []
})
describe('scanNestedRepos', () => {
it('returns child repos for a non-git parent', async () => {
const root = await tempRoot()
await mkdir(join(root, 'auth-service'), { recursive: true })
await mkdir(join(root, 'billing-service'), { recursive: true })
await makeGitRepo(join(root, 'auth-service'))
await makeGitRepo(join(root, 'billing-service'))
const result = await scanNestedRepos({ path: root })
expect(result.selectedPathKind).toBe('non_git_folder')
expect(result.repos.map((repo) => repo.displayName)).toEqual([
'auth-service',
'billing-service'
])
})
it('does not scan inside an already discovered repo', async () => {
const root = await tempRoot()
await mkdir(join(root, 'service', 'nested'), { recursive: true })
await makeGitRepo(join(root, 'service'))
await makeGitRepo(join(root, 'service', 'nested'))
const result = await scanNestedRepos({ path: root })
expect(result.repos.map((repo) => repo.displayName)).toEqual(['service'])
})
it('skips heavy directories and respects result caps', async () => {
const root = await tempRoot()
await mkdir(join(root, 'node_modules', 'ignored'), { recursive: true })
await mkdir(join(root, 'one'), { recursive: true })
await mkdir(join(root, 'two'), { recursive: true })
await makeGitRepo(join(root, 'node_modules', 'ignored'))
await makeGitRepo(join(root, 'one'))
await makeGitRepo(join(root, 'two'))
const result = await scanNestedRepos({ path: root, options: { maxRepos: 1 } })
expect(result.repos[0].displayName).toBe('one')
expect(result.truncated).toBe(true)
})
it('treats a selected git repo as the existing repo path', async () => {
const root = await tempRoot()
await makeGitRepo(root)
await mkdir(join(root, 'child'), { recursive: true })
await makeGitRepo(join(root, 'child'))
await writeFile(join(root, 'README.md'), '')
const result = await scanNestedRepos({ path: root })
expect(result.selectedPathKind).toBe('git_repo')
expect(result.repos).toEqual([])
})
})

View File

@ -0,0 +1,172 @@
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import type {
NestedRepoCandidate,
NestedRepoScanOptions,
NestedRepoScanResult
} from '../../shared/types'
import { isGitRepo } from '../git/repo'
type NestedRepoDirectoryEntry = {
name: string
isDirectory: boolean
}
type NestedRepoScanFilesystem = {
readDirectory: (dirPath: string) => Promise<NestedRepoDirectoryEntry[]>
joinPath: (parentPath: string, childName: string) => string
basename: (path: string) => string
isGitRepoPath: (path: string) => Promise<boolean> | boolean
}
const DEFAULT_MAX_DEPTH = 3
const DEFAULT_MAX_REPOS = 100
const DEFAULT_TIMEOUT_MS = 8_000
const SKIPPED_DIRS = new Set([
'node_modules',
'.next',
'dist',
'build',
'.cache',
'vendor',
'__pycache__',
'.turbo',
'.parcel-cache'
])
function normalizeScanOptions(options: unknown): Required<NestedRepoScanOptions> {
const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {}
return {
maxDepth:
typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth)
? Math.max(1, Math.min(8, Math.floor(raw.maxDepth)))
: DEFAULT_MAX_DEPTH,
maxRepos:
typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos)
? Math.max(1, Math.min(500, Math.floor(raw.maxRepos)))
: DEFAULT_MAX_REPOS,
timeoutMs:
typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs)
? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs)))
: DEFAULT_TIMEOUT_MS
}
}
function shouldSkipDirectory(name: string, depth: number): boolean {
if (SKIPPED_DIRS.has(name)) {
return true
}
return depth > 0 && name.startsWith('.')
}
async function hasGitMarker(dirPath: string): Promise<boolean> {
try {
const marker = await stat(join(dirPath, '.git'))
return marker.isDirectory() || marker.isFile()
} catch {
return false
}
}
async function readLocalDirectory(dirPath: string): Promise<NestedRepoDirectoryEntry[]> {
const entries = await readdir(dirPath)
const result: NestedRepoDirectoryEntry[] = []
for (const name of entries) {
const childStat = await stat(join(dirPath, name)).catch(() => null)
result.push({ name, isDirectory: childStat?.isDirectory() === true })
}
return result
}
export async function scanNestedRepos(args: {
path: string
options?: unknown
filesystem?: NestedRepoScanFilesystem
}): Promise<NestedRepoScanResult> {
const startedAt = Date.now()
const options = normalizeScanOptions(args.options)
const repos: NestedRepoCandidate[] = []
let truncated = false
let timedOut = false
const filesystem = args.filesystem ?? {
readDirectory: readLocalDirectory,
joinPath: join,
basename,
isGitRepoPath: async (path: string) => isGitRepo(path) || (await hasGitMarker(path))
}
if (await filesystem.isGitRepoPath(args.path)) {
return {
selectedPath: args.path,
selectedPathKind: 'git_repo',
repos: [],
truncated: false,
timedOut: false,
durationMs: Date.now() - startedAt,
maxDepth: options.maxDepth
}
}
const visit = async (dirPath: string, depth: number): Promise<void> => {
if (repos.length >= options.maxRepos) {
truncated = true
return
}
if (Date.now() - startedAt > options.timeoutMs) {
timedOut = true
return
}
if (depth > options.maxDepth) {
return
}
let entries: NestedRepoDirectoryEntry[]
try {
entries = await filesystem.readDirectory(dirPath)
} catch {
return
}
const dirs = entries
.filter((entry) => entry.isDirectory)
.sort((left, right) => left.name.localeCompare(right.name))
for (const entry of dirs) {
const name = entry.name
if (repos.length >= options.maxRepos) {
truncated = true
return
}
if (Date.now() - startedAt > options.timeoutMs) {
timedOut = true
return
}
if (shouldSkipDirectory(name, depth)) {
continue
}
const childPath = filesystem.joinPath(dirPath, name)
if (await filesystem.isGitRepoPath(childPath)) {
repos.push({
path: childPath,
displayName: filesystem.basename(childPath),
depth: depth + 1
})
// Project Groups organize sibling repos; nested repos stay hidden until a
// later UI can explain and select submodule-style layouts explicitly.
continue
}
await visit(childPath, depth + 1)
}
}
await visit(args.path, 0)
return {
selectedPath: args.path,
selectedPathKind: 'non_git_folder',
repos,
truncated,
timedOut,
durationMs: Date.now() - startedAt,
maxDepth: options.maxDepth
}
}

View File

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import { createNestedProjectGroupResolver, resolveNestedRepoSelection } from './nested-repo-import'
import type { ProjectGroup } from '../../shared/types'
describe('createNestedProjectGroupResolver', () => {
it('creates a root group plus intermediate directory groups for nested repos', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'group',
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
const direct = resolver.getGroupForRepo('/workspace/gateway-api')
const nested = resolver.getGroupForRepo('/workspace/services/payments/api')
const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker')
expect(direct?.name).toBe('workspace')
expect(nested?.name).toBe('payments')
expect(sibling?.id).toBe(nested?.id)
expect(groups.map((group) => [group.name, group.parentGroupId])).toEqual([
['workspace', null],
['services', 'group-0'],
['payments', 'group-1']
])
expect(resolver.getRootGroup()?.id).toBe('group-0')
})
it('does not create groups for separate imports', () => {
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'separate',
createGroup: () => {
throw new Error('should not create a group')
}
})
expect(resolver.getGroupForRepo('/workspace/services/api')).toBeUndefined()
expect(resolver.getCreatedGroups()).toEqual([])
})
it('preserves filesystem root parent paths when creating groups', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: '/',
groupName: 'root',
mode: 'group',
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
resolver.getGroupForRepo('/api')
resolver.getGroupForRepo('/services/api')
expect(groups.map((group) => group.parentPath)).toEqual(['/', '/services'])
})
it('preserves Windows drive roots when creating groups', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: 'C:\\',
groupName: 'C',
mode: 'group',
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
resolver.getGroupForRepo('C:\\api')
resolver.getGroupForRepo('C:\\services\\api')
expect(groups.map((group) => group.parentPath)).toEqual(['C:/', 'C:/services'])
})
it('resolves Windows-style repo paths back to canonical scan output', () => {
const selection = resolveNestedRepoSelection({
scan: {
selectedPath: 'C:\\workspace',
selectedPathKind: 'non_git_folder',
repos: [
{ path: 'C:\\workspace\\Services\\API', displayName: 'API', depth: 2 },
{ path: 'C:\\workspace\\tools', displayName: 'tools', depth: 1 }
],
truncated: false,
timedOut: false,
durationMs: 1,
maxDepth: 3
},
projectPaths: ['c:/workspace/services/api', 'C:/workspace/services/api', 'D:/other/repo']
})
expect(selection.selectedPaths).toEqual(['C:\\workspace\\Services\\API'])
expect(selection.rejectedPaths).toEqual(['D:/other/repo'])
})
})

View File

@ -0,0 +1,144 @@
import type { NestedRepoScanResult, ProjectGroup, ProjectGroupImportMode } from '../../shared/types'
import {
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
type CreateGroupInput = {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}
type NestedProjectGroupResolver = {
getGroupForRepo: (repoPath: string) => ProjectGroup | undefined
getRootGroup: () => ProjectGroup | undefined
getCreatedGroups: () => ProjectGroup[]
}
export type ResolvedNestedRepoSelection = {
selectedPaths: string[]
rejectedPaths: string[]
}
function trimPathSeparators(path: string): string {
if (path === '/' || /^[A-Za-z]:[\\/]?$/.test(path)) {
return path.replace(/\\/g, '/')
}
if (/^\/\/[^/]+\/[^/]+\/?$/.test(path.replace(/\\/g, '/'))) {
return path.replace(/\\/g, '/').replace(/\/$/, '')
}
return path.replace(/[\\/]+$/g, '')
}
function splitPath(path: string): string[] {
return trimPathSeparators(path)
.split(/[\\/]+/)
.filter(Boolean)
}
function joinPath(parentPath: string, segments: readonly string[]): string {
const trimmedParent = trimPathSeparators(parentPath)
const separator = trimmedParent.includes('\\') && !trimmedParent.includes('/') ? '\\' : '/'
return segments.length === 0
? trimmedParent
: trimmedParent === '/'
? `/${segments.join('/')}`
: trimmedParent.endsWith(separator)
? `${trimmedParent}${segments.join(separator)}`
: `${trimmedParent}${separator}${segments.join(separator)}`
}
function getRelativeSegments(parentPath: string, repoPath: string): string[] {
const relativePath = relativePathInsideRoot(parentPath, repoPath)
if (relativePath !== null) {
return splitPath(relativePath)
}
const normalizedParent = trimPathSeparators(parentPath)
const normalizedRepo = trimPathSeparators(repoPath)
const parentWithSeparator = `${normalizedParent}/`
const normalizedRepoForMatch = normalizedRepo.replace(/\\/g, '/')
const normalizedParentForMatch = normalizedParent.replace(/\\/g, '/')
const parentWithMatchSeparator = `${normalizedParentForMatch}/`
if (normalizedRepoForMatch.startsWith(parentWithMatchSeparator)) {
return splitPath(normalizedRepoForMatch.slice(parentWithMatchSeparator.length))
}
if (normalizedRepo.startsWith(parentWithSeparator)) {
return splitPath(normalizedRepo.slice(parentWithSeparator.length))
}
return splitPath(normalizedRepo).slice(-1)
}
export function createNestedProjectGroupResolver(args: {
parentPath: string
groupName: string
mode: ProjectGroupImportMode
createGroup: (input: CreateGroupInput) => ProjectGroup
}): NestedProjectGroupResolver {
const createdGroups: ProjectGroup[] = []
const groupsByRelativeDir = new Map<string, ProjectGroup>()
const ensureGroup = (relativeDirs: readonly string[]): ProjectGroup | undefined => {
if (args.mode !== 'group') {
return undefined
}
const key = relativeDirs.join('/')
const existing = groupsByRelativeDir.get(key)
if (existing) {
return existing
}
const parentDirs = relativeDirs.slice(0, -1)
const parentGroup = relativeDirs.length > 0 ? ensureGroup(parentDirs) : undefined
const group = args.createGroup({
name: relativeDirs.length === 0 ? args.groupName : (relativeDirs.at(-1) ?? args.groupName),
parentPath: joinPath(args.parentPath, relativeDirs),
parentGroupId: parentGroup?.id ?? null,
createdFrom: 'folder-scan'
})
groupsByRelativeDir.set(key, group)
createdGroups.push(group)
return group
}
return {
getGroupForRepo: (repoPath) => {
const segments = getRelativeSegments(args.parentPath, repoPath)
// Why: direct child repos belong to the selected-folder group; nested repos
// belong to the deepest intermediate directory group.
return ensureGroup(segments.slice(0, -1))
},
getRootGroup: () => groupsByRelativeDir.get(''),
getCreatedGroups: () => [...createdGroups]
}
}
export function resolveNestedRepoSelection(args: {
scan: NestedRepoScanResult
projectPaths: readonly string[]
}): ResolvedNestedRepoSelection {
const candidatesByPath = new Map(
args.scan.repos.map((repo) => [normalizeRuntimePathForComparison(repo.path), repo.path])
)
const selectedPaths: string[] = []
const rejectedPaths: string[] = []
const seen = new Set<string>()
for (const repoPath of args.projectPaths) {
const normalizedPath = normalizeRuntimePathForComparison(repoPath)
if (seen.has(normalizedPath)) {
continue
}
seen.add(normalizedPath)
const canonicalPath = candidatesByPath.get(normalizedPath)
if (canonicalPath) {
selectedPaths.push(canonicalPath)
} else {
// Why: imports are derived from a bounded scan of this parent folder;
// callers must not smuggle unrelated paths into the group hierarchy.
rejectedPaths.push(repoPath)
}
}
return { selectedPaths, rejectedPaths }
}

View File

@ -619,6 +619,26 @@ computeWorktreePathMock.mockImplementation(
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
describe('OrcaRuntimeService', () => {
it('rejects relative paths for runtime nested repo scan/import', async () => {
const runtime = new OrcaRuntimeService({
...store,
createProjectGroup: vi.fn(),
moveProjectToGroup: vi.fn()
} as never)
await expect(runtime.scanNestedRepos('relative/project')).rejects.toThrow(
'Project path must be an absolute path'
)
await expect(
runtime.importNestedRepos({
parentPath: 'relative/project',
groupName: 'Project',
projectPaths: ['relative/project/api'],
mode: 'group'
})
).rejects.toThrow('Project path must be an absolute path')
})
it('starts unavailable with no authoritative window', () => {
const runtime = createRuntime()

View File

@ -43,11 +43,16 @@ import type {
WorktreeStartupLaunch,
LinearIssueUpdate,
LinearWorkspaceSelection,
NestedRepoScanResult,
ProjectGroup,
ProjectGroupImportMode,
ProjectGroupImportResult,
TabGroupLayoutNode,
TuiAgent
} from '../../shared/types'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
import { isFolderRepo } from '../../shared/repo-kind'
import { getNextProjectGroupOrder } from '../../shared/project-groups'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
import { FIRST_PANE_ID } from '../../shared/pane-key'
@ -372,6 +377,16 @@ import type { RateLimitState } from '../../shared/rate-limit-types'
import type { VoiceSettings } from '../../shared/speech-types'
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
import {
createNestedProjectGroupResolver,
resolveNestedRepoSelection
} from '../project-groups/nested-repo-import'
function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string {
console.warn(`[project-groups] ${context}`, error)
return 'Repository could not be imported'
}
type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
@ -399,7 +414,12 @@ type RuntimeStore = {
getRepo: Store['getRepo']
addRepo: Store['addRepo']
updateRepo: Store['updateRepo']
removeRepo?: Store['removeRepo']
getProjectGroups?: Store['getProjectGroups']
createProjectGroup?: Store['createProjectGroup']
updateProjectGroup?: Store['updateProjectGroup']
deleteProjectGroup?: Store['deleteProjectGroup']
moveProjectToGroup?: Store['moveProjectToGroup']
removeProject?: Store['removeProject']
reorderRepos?: Store['reorderRepos']
getAllWorktreeMeta: Store['getAllWorktreeMeta']
getWorktreeMeta: Store['getWorktreeMeta']
@ -5071,6 +5091,171 @@ export class OrcaRuntimeService {
return this.store?.getRepos() ?? []
}
listProjectGroups(): ProjectGroup[] {
return this.store?.getProjectGroups?.() ?? []
}
async createProjectGroup(input: {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom?: ProjectGroup['createdFrom']
}): Promise<ProjectGroup> {
if (!this.store?.createProjectGroup) {
throw new Error('runtime_unavailable')
}
const group = this.store.createProjectGroup({
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom ?? 'manual'
})
this.notifier?.reposChanged()
return group
}
async updateProjectGroup(
groupId: string,
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
): Promise<ProjectGroup | null> {
if (!this.store?.updateProjectGroup) {
throw new Error('runtime_unavailable')
}
const updated = this.store.updateProjectGroup(groupId, updates)
if (updated) {
this.notifier?.reposChanged()
}
return updated
}
async deleteProjectGroup(groupId: string): Promise<{ deleted: boolean }> {
if (!this.store?.deleteProjectGroup) {
throw new Error('runtime_unavailable')
}
const deleted = this.store.deleteProjectGroup(groupId)
if (deleted) {
this.notifier?.reposChanged()
}
return { deleted }
}
async moveProjectToGroup(
repoSelector: string,
groupId: string | null,
order?: number
): Promise<Repo> {
if (!this.store?.moveProjectToGroup) {
throw new Error('runtime_unavailable')
}
const repo = await this.resolveRepoSelector(repoSelector)
const moved = this.store.moveProjectToGroup(repo.id, groupId, order)
if (!moved) {
throw new Error('repo_not_found')
}
this.notifier?.reposChanged()
return moved
}
async scanNestedRepos(path: string): Promise<NestedRepoScanResult> {
if (!isAbsolute(path)) {
throw new Error('Project path must be an absolute path')
}
return scanNestedRepos({ path })
}
async importNestedRepos(args: {
parentPath: string
groupName: string
projectPaths: string[]
mode: ProjectGroupImportMode
}): Promise<ProjectGroupImportResult> {
if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) {
throw new Error('runtime_unavailable')
}
if (!isAbsolute(args.parentPath)) {
throw new Error('Project path must be an absolute path')
}
const scan = await scanNestedRepos({ path: args.parentPath })
const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths })
const groupResolver = createNestedProjectGroupResolver({
parentPath: scan.selectedPath,
groupName: args.groupName,
mode: args.mode,
createGroup: (input) => this.store!.createProjectGroup!(input)
})
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
(repoPath) => ({
path: repoPath,
status: 'failed',
error: 'Repository was not found in the nested repo scan result'
})
)
for (const repoPath of selection.selectedPaths) {
try {
if (!isGitRepo(repoPath)) {
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
continue
}
const existing = this.store
.getRepos()
.find((repo) => runtimePathsEqual(repo.path, repoPath))
const group = groupResolver.getGroupForRepo(repoPath)
if (existing) {
if (group) {
this.store.moveProjectToGroup(existing.id, group.id)
}
results.push({ path: repoPath, projectId: existing.id, status: 'already-known' })
continue
}
const repo: Repo = {
id: randomUUID(),
path: repoPath,
displayName: getRepoName(repoPath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
...(group
? {
projectGroupId: group.id,
projectGroupOrder: getNextProjectGroupOrder(this.store.getRepos(), group.id)
}
: {})
}
this.store.addRepo(repo)
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
} catch (error) {
results.push({
path: repoPath,
status: 'failed',
error: sanitizeNestedRepoRuntimeImportError(
'Failed to import nested repository in runtime',
error
)
})
}
}
const importedCount = results.filter((entry) => entry.status === 'imported').length
const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length
const failedCount = results.filter((entry) => entry.status === 'failed').length
if (importedCount + alreadyKnownCount === 0) {
for (const group of groupResolver.getCreatedGroups().reverse()) {
this.store.deleteProjectGroup?.(group.id)
}
}
this.invalidateResolvedWorktreeCache()
this.notifier?.reposChanged()
const rootGroup = groupResolver.getRootGroup()
return {
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
projects: results,
importedCount,
alreadyKnownCount,
failedCount
}
}
async listSparsePresets(repoSelector: string) {
if (!this.store?.getSparsePresets) {
throw new Error('runtime_unavailable')
@ -5110,7 +5295,7 @@ export class OrcaRuntimeService {
if (!isAbsolute(path)) {
// Why: remote clients may run in a different cwd than the server. Require
// server-side repo paths to be explicit so `orca serve` cwd is irrelevant.
throw new Error('Repo path must be an absolute path')
throw new Error('Project path must be an absolute path')
}
if (kind === 'git' && !isGitRepo(path)) {
throw new Error(`Not a valid git repository: ${path}`)
@ -5367,6 +5552,8 @@ export class OrcaRuntimeService {
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
@ -5384,12 +5571,12 @@ export class OrcaRuntimeService {
return updated
}
async removeRepo(repoSelector: string): Promise<{ removed: true }> {
if (!this.store?.removeRepo) {
async removeProject(repoSelector: string): Promise<{ removed: true }> {
if (!this.store?.removeProject) {
throw new Error('runtime_unavailable')
}
const repo = await this.resolveRepoSelector(repoSelector)
this.store.removeRepo(repo.id)
this.store.removeProject(repo.id)
this.invalidateResolvedWorktreeCache()
invalidateAuthorizedRootsCache()
this.notifier?.reposChanged()

View File

@ -61,13 +61,33 @@ describe('repo RPC methods', () => {
})
})
it('shows a repo with the CLI-compatible response shape', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
showRepo: vi.fn().mockResolvedValue({
id: 'repo-1',
path: '/srv/projects/orca',
kind: 'git'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(makeRequest('repo.show', { repo: 'repo-1' }))
expect(runtime.showRepo).toHaveBeenCalledWith('repo-1')
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', path: '/srv/projects/orca' } }
})
})
it('lists sparse checkout presets for a repo', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listSparsePresets: vi.fn().mockResolvedValue([
{
id: 'preset-1',
repoId: 'repo-1',
projectId: 'repo-1',
name: 'Frontend',
directories: ['src/renderer'],
createdAt: 1,
@ -93,7 +113,7 @@ describe('repo RPC methods', () => {
getRuntimeId: () => 'test-runtime',
saveSparsePreset: vi.fn().mockResolvedValue({
id: 'preset-1',
repoId: 'repo-1',
projectId: 'repo-1',
name: 'Frontend',
directories: ['src/renderer'],
createdAt: 1,
@ -205,4 +225,99 @@ describe('repo RPC methods', () => {
result: { repo: { id: 'repo-1', issueSourcePreference: 'origin' } }
})
})
it('routes project group mutations to the runtime server', async () => {
const group = {
id: 'group-1',
name: 'Platform',
parentPath: '/srv/platform',
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const runtime = {
getRuntimeId: () => 'test-runtime',
listProjectGroups: vi.fn().mockReturnValue([group]),
createProjectGroup: vi.fn().mockResolvedValue(group),
updateProjectGroup: vi.fn().mockResolvedValue({ ...group, name: 'Core' }),
deleteProjectGroup: vi.fn().mockResolvedValue({ deleted: true }),
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
await dispatcher.dispatch(makeRequest('projectGroup.list'))
await dispatcher.dispatch(
makeRequest('projectGroup.create', {
name: 'Platform',
parentPath: '/srv/platform',
createdFrom: 'folder-scan'
})
)
await dispatcher.dispatch(
makeRequest('projectGroup.update', {
groupId: group.id,
updates: { name: 'Core', isCollapsed: true }
})
)
await dispatcher.dispatch(makeRequest('projectGroup.delete', { groupId: group.id }))
const moveResponse = await dispatcher.dispatch(
makeRequest('projectGroup.moveProject', {
repo: 'repo-1',
groupId: group.id,
order: 2
})
)
expect(runtime.listProjectGroups).toHaveBeenCalled()
expect(runtime.createProjectGroup).toHaveBeenCalledWith({
name: 'Platform',
parentPath: '/srv/platform',
createdFrom: 'folder-scan'
})
expect(runtime.updateProjectGroup).toHaveBeenCalledWith(group.id, {
name: 'Core',
isCollapsed: true
})
expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id)
expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2)
expect(moveResponse).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', projectGroupId: group.id } }
})
})
it('allows separate nested-repo imports without a group name', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
importNestedRepos: vi.fn().mockResolvedValue({
repos: [{ path: '/srv/platform/api', projectId: 'repo-1', status: 'imported' }],
importedCount: 1,
alreadyKnownCount: 0,
failedCount: 0
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('projectGroup.importNested', {
parentPath: '/srv/platform',
projectPaths: ['/srv/platform/api'],
mode: 'separate'
})
)
expect(runtime.importNestedRepos).toHaveBeenCalledWith({
parentPath: '/srv/platform',
groupName: '',
projectPaths: ['/srv/platform/api'],
mode: 'separate'
})
expect(response).toMatchObject({
ok: true,
result: { importedCount: 1, failedCount: 0 }
})
})
})

View File

@ -51,6 +51,8 @@ const RepoUpdate = RepoSelector.extend({
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
externalWorktreeVisibility: z.enum(['hide', 'show']).optional(),
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
projectGroupId: OptionalString.nullable().optional(),
projectGroupOrder: OptionalFiniteNumber,
sourceControlAi: RepoSourceControlAiOverrides
})
})
@ -68,6 +70,54 @@ const RepoReorder = z.object({
orderedIds: z.array(z.string())
})
const ProjectGroupCreate = z.object({
name: requiredString('Missing group name'),
parentPath: OptionalString,
parentGroupId: OptionalString.nullable().optional(),
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
})
const ProjectGroupUpdate = z.object({
groupId: requiredString('Missing group id'),
updates: z.object({
name: OptionalString,
isCollapsed: z.boolean().optional(),
tabOrder: OptionalFiniteNumber,
color: OptionalString.nullable().optional()
})
})
const ProjectGroupSelector = z.object({
groupId: requiredString('Missing group id')
})
const ProjectGroupMoveProject = z.object({
repo: requiredString('Missing repo selector'),
groupId: OptionalString.nullable(),
order: OptionalFiniteNumber
})
const ProjectGroupScanNested = z.object({
path: requiredString('Missing folder path')
})
const ProjectGroupImportNested = z.discriminatedUnion('mode', [
z.object({
parentPath: requiredString('Missing parent path'),
groupName: requiredString('Missing group name'),
projectPaths: z.array(z.string()),
mode: z.literal('group')
}),
z.object({
parentPath: requiredString('Missing parent path'),
// Why: "Import separately" does not create a group, so SSH must accept the
// same empty group-name state that the local dialog allows.
groupName: z.string().optional().default(''),
projectPaths: z.array(z.string()),
mode: z.literal('separate')
})
])
const RepoIssueCommandWrite = RepoSelector.extend({
content: z.string()
})
@ -84,6 +134,47 @@ export const REPO_METHODS: RpcMethod[] = [
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
}),
defineMethod({
name: 'projectGroup.list',
params: null,
handler: (_params, { runtime }) => ({ groups: runtime.listProjectGroups() })
}),
defineMethod({
name: 'projectGroup.create',
params: ProjectGroupCreate,
handler: async (params, { runtime }) => ({
group: await runtime.createProjectGroup(params)
})
}),
defineMethod({
name: 'projectGroup.update',
params: ProjectGroupUpdate,
handler: async (params, { runtime }) => ({
group: await runtime.updateProjectGroup(params.groupId, params.updates)
})
}),
defineMethod({
name: 'projectGroup.delete',
params: ProjectGroupSelector,
handler: async (params, { runtime }) => runtime.deleteProjectGroup(params.groupId)
}),
defineMethod({
name: 'projectGroup.moveProject',
params: ProjectGroupMoveProject,
handler: async (params, { runtime }) => ({
repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order)
})
}),
defineMethod({
name: 'projectGroup.scanNested',
params: ProjectGroupScanNested,
handler: async (params, { runtime }) => runtime.scanNestedRepos(params.path)
}),
defineMethod({
name: 'projectGroup.importNested',
params: ProjectGroupImportNested,
handler: async (params, { runtime }) => runtime.importNestedRepos(params)
}),
defineMethod({
name: 'repo.sparsePresets',
params: RepoSelector,
@ -140,7 +231,7 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.rm',
params: RepoSelector,
handler: async (params, { runtime }) => runtime.removeRepo(params.repo)
handler: async (params, { runtime }) => runtime.removeProject(params.repo)
}),
defineMethod({
name: 'repo.reorder',

View File

@ -262,16 +262,16 @@ export function buildSkillDiscoverySources(
)
]
const repoPaths = new Set<string>()
const projectPaths = new Set<string>()
for (const repo of args.repos ?? []) {
if (repo.connectionId) {
continue
}
repoPaths.add(repo.path)
projectPaths.add(repo.path)
}
repoPaths.add(cwd)
projectPaths.add(cwd)
for (const repoPath of repoPaths) {
for (const repoPath of projectPaths) {
const label = `Repo ${basename(repoPath)}`
roots.push(
source(

View File

@ -90,9 +90,13 @@ import type {
PRInfo,
PRRefreshOutcome,
Repo,
ProjectGroup,
ProjectGroupImportResult,
ProjectGroupImportMode,
ShellHydrationFailureReason,
SparsePreset,
SearchOptions,
NestedRepoScanResult,
SearchResult,
StatsSummary,
MemorySnapshot,
@ -629,6 +633,8 @@ export type PreloadApi = {
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
@ -661,6 +667,37 @@ export type PreloadApi = {
}) => Promise<BaseRefSearchResult[]>
onChanged: (callback: () => void) => () => void
}
projectGroups: {
list: () => Promise<ProjectGroup[]>
create: (args: {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom?: ProjectGroup['createdFrom']
}) => Promise<ProjectGroup>
update: (args: {
groupId: string
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
}) => Promise<ProjectGroup | null>
delete: (args: { groupId: string }) => Promise<boolean>
moveProject: (args: {
projectId: string
groupId: string | null
order?: number
}) => Promise<Repo | null>
scanNested: (args: {
path: string
connectionId?: string
options?: Record<string, unknown>
}) => Promise<NestedRepoScanResult>
importNested: (args: {
parentPath: string
groupName: string
projectPaths: string[]
connectionId?: string
mode: ProjectGroupImportMode
}) => Promise<ProjectGroupImportResult>
}
sparsePresets: {
list: (args: { repoId: string }) => Promise<SparsePreset[]>
save: (args: {

View File

@ -455,6 +455,37 @@ const api = {
}
},
projectGroups: {
list: (): Promise<unknown[]> => ipcRenderer.invoke('projectGroups:list'),
create: (args: {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom?: 'manual' | 'folder-scan' | 'migration'
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:create', args),
update: (args: { groupId: string; updates: Record<string, unknown> }): Promise<unknown> =>
ipcRenderer.invoke('projectGroups:update', args),
delete: (args: { groupId: string }): Promise<boolean> =>
ipcRenderer.invoke('projectGroups:delete', args),
moveProject: (args: {
projectId: string
groupId: string | null
order?: number
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:moveProject', args),
scanNested: (args: {
path: string
connectionId?: string
options?: Record<string, unknown>
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:scanNested', args),
importNested: (args: {
parentPath: string
groupName: string
projectPaths: string[]
connectionId?: string
mode: 'group' | 'separate'
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:importNested', args)
},
sparsePresets: {
list: (args: { repoId: string }): Promise<unknown[]> =>
ipcRenderer.invoke('sparsePresets:list', args),

View File

@ -267,6 +267,7 @@ function App(): React.JSX.Element {
useShallow((s) => ({
toggleSidebar: s.toggleSidebar,
fetchRepos: s.fetchRepos,
fetchProjectGroups: s.fetchProjectGroups,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchSettings: s.fetchSettings,
@ -531,6 +532,7 @@ function App(): React.JSX.Element {
// the local filesystem and then hydrate stale local workspace state.
await actions.fetchSettings()
await actions.fetchRepos()
await actions.fetchProjectGroups()
await actions.fetchAllWorktrees()
await actions.fetchWorktreeLineage()
const persistedUI = await window.api.ui.get()

View File

@ -1,4 +1,4 @@
import { useMemo, useSyncExternalStore } from 'react'
import { useSyncExternalStore } from 'react'
type BrowserAutomationVisibilityBridge = {
acquire: (browserPageId: string) => Promise<string | null>
@ -75,11 +75,8 @@ export function getBrowserAutomationVisiblePageIds(browserPageIds: readonly stri
}
export function useBrowserAutomationVisiblePageIds(browserPageIds: readonly string[]): Set<string> {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return useMemo(
() => getBrowserAutomationVisiblePageIds(browserPageIds),
[browserPageIds, snapshot]
)
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return getBrowserAutomationVisiblePageIds(browserPageIds)
}
export function acquireBrowserAutomationVisibility(browserPageId: string): string {

View File

@ -41,7 +41,7 @@ export type DashboardWorktreeCard = {
agents: DashboardAgentRow[]
}
export type DashboardRepoGroup = {
export type DashboardProjectGroup = {
repo: Repo
worktrees: DashboardWorktreeCard[]
}
@ -107,7 +107,7 @@ function buildDashboardData(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>,
now: number
): DashboardRepoGroup[] {
): DashboardProjectGroup[] {
// Why: build a tabId -> entries index once per computation instead of
// re-scanning every agent status entry inside the per-tab loop. paneKey is
// formatted as `${tabId}:${leafId}`; parsePaneKey also drops legacy numeric
@ -150,7 +150,7 @@ function buildDashboardData(
return { repo, worktree, agents } satisfies DashboardWorktreeCard
})
return { repo, worktrees } satisfies DashboardRepoGroup
return { repo, worktrees } satisfies DashboardProjectGroup
})
}
@ -162,7 +162,7 @@ function buildDashboardData(
* Not used to render anything directly the inline list reads its own
* worktree-scoped slice via useWorktreeAgentRows.
*/
export function useDashboardData(): DashboardRepoGroup[] {
export function useDashboardData(): DashboardProjectGroup[] {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)

View File

@ -729,7 +729,7 @@ export function FloatingTerminalPanel({
context,
terminalShortcutPolicy: state.settings?.terminalShortcutPolicy
}
const nativeEvent = event.nativeEvent ?? event
const nativeEvent = event.nativeEvent
const matches = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, nativeEvent, platform, state.keybindings, matchOptions)

View File

@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronLeft, CornerDownLeft, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isEditableTarget } from '@/lib/editable-target'
import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
@ -14,6 +13,7 @@ import { RepoStep } from './RepoStep'
import { OnboardingTourStep } from './OnboardingTourStep'
import { STEPS, useOnboardingFlow } from './use-onboarding-flow'
import { OnboardingSkipConfirmationDialog } from './OnboardingSkipConfirmationDialog'
import { OnboardingFooter } from './OnboardingFooter'
import logo from '../../../../../resources/logo.svg'
const stepCopy = {
@ -287,7 +287,11 @@ export default function OnboardingFlow({
? 'mt-7 overflow-hidden'
: cn(
'scrollbar-sleek overflow-y-auto pr-1',
currentStep.id === 'agentSetup' ? 'mt-4' : 'mt-10'
currentStep.id === 'agentSetup'
? 'mt-4'
: currentStep.id === 'repo'
? 'mt-6'
: 'mt-10'
)
)}
>
@ -335,6 +339,13 @@ export default function OnboardingFlow({
<RepoStep
cloneUrl={flow.cloneUrl}
onCloneUrlChange={flow.setCloneUrl}
nestedScan={flow.nestedScan}
nestedSelectedPaths={flow.nestedSelectedPaths}
onNestedSelectedPathsChange={flow.setNestedSelectedPaths}
nestedGroupName={flow.nestedGroupName}
onNestedGroupNameChange={flow.setNestedGroupName}
onImportNested={(mode) => void flow.importNested(mode)}
onCancelNested={flow.cancelNested}
onOpenFolder={() => void flow.openFolder()}
onOpenServerFolder={(kind) => void flow.openFolder(kind)}
onClone={() => void flow.clone()}
@ -352,56 +363,28 @@ export default function OnboardingFlow({
</div>
{shouldShowFooter && (
<footer className="mt-6 flex flex-none items-center justify-between border-t border-border pt-5">
{shouldShowSkipToProjectSetup ? (
<button
className="rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-muted-foreground"
disabled={Boolean(busyLabel)}
onClick={() => void flow.skipToRepo()}
>
Skip to project setup
</button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{stepIndex > 0 && (
<button
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-60"
disabled={Boolean(busyLabel)}
onClick={flow.back}
>
<ChevronLeft className="size-4" />
Back
</button>
)}
{(currentStep.id !== 'repo' || flow.hasExistingProject) && (
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
aria-busy={shouldShowFooterBusy}
disabled={Boolean(busyLabel)}
onClick={() => {
if (isTourStep) {
void flow.skipTourToRepo()
return
}
if (currentStep.id === 'repo') {
void flow.continueWithExistingProject()
return
}
void flow.next()
}}
>
{shouldShowFooterBusy ? <Loader2 className="size-4 animate-spin" /> : null}
{footerPrimaryLabel}
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
<span>{continueShortcutModifierLabel}</span>
<CornerDownLeft className="size-3" />
</span>
</button>
)}
</div>
</footer>
<OnboardingFooter
shouldShowSkipToProjectSetup={shouldShowSkipToProjectSetup}
busyLabel={busyLabel}
onSkipToRepo={() => void flow.skipToRepo()}
stepIndex={stepIndex}
onBack={flow.back}
showPrimary={currentStep.id !== 'repo' || flow.hasExistingProject}
primaryBusy={shouldShowFooterBusy}
primaryLabel={footerPrimaryLabel}
shortcutModifierLabel={continueShortcutModifierLabel}
onPrimary={() => {
if (isTourStep) {
void flow.skipTourToRepo()
return
}
if (currentStep.id === 'repo') {
void flow.continueWithExistingProject()
return
}
void flow.next()
}}
/>
)}
</div>
</section>

View File

@ -0,0 +1,70 @@
import { ChevronLeft, CornerDownLeft, Loader2 } from 'lucide-react'
type OnboardingFooterProps = {
shouldShowSkipToProjectSetup: boolean
busyLabel: string | null
onSkipToRepo: () => void
stepIndex: number
onBack: () => void
showPrimary: boolean
primaryBusy: boolean
primaryLabel: string
shortcutModifierLabel: string
onPrimary: () => void
}
export function OnboardingFooter({
shouldShowSkipToProjectSetup,
busyLabel,
onSkipToRepo,
stepIndex,
onBack,
showPrimary,
primaryBusy,
primaryLabel,
shortcutModifierLabel,
onPrimary
}: OnboardingFooterProps): React.JSX.Element {
return (
<footer className="mt-6 flex flex-none items-center justify-between border-t border-border pt-5">
{shouldShowSkipToProjectSetup ? (
<button
className="rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-muted-foreground"
disabled={Boolean(busyLabel)}
onClick={onSkipToRepo}
>
Skip to project setup
</button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{stepIndex > 0 && (
<button
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-60"
disabled={Boolean(busyLabel)}
onClick={onBack}
>
<ChevronLeft className="size-4" />
Back
</button>
)}
{showPrimary && (
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
aria-busy={primaryBusy}
disabled={Boolean(busyLabel)}
onClick={onPrimary}
>
{primaryBusy ? <Loader2 className="size-4 animate-spin" /> : null}
{primaryLabel}
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
<span>{shortcutModifierLabel}</span>
<CornerDownLeft className="size-3" />
</span>
</button>
)}
</div>
</footer>
)
}

View File

@ -8,6 +8,13 @@ function renderRepoStep(overrides: Partial<ComponentProps<typeof RepoStep>> = {}
<RepoStep
cloneUrl=""
onCloneUrlChange={vi.fn()}
nestedScan={null}
nestedSelectedPaths={new Set()}
onNestedSelectedPathsChange={vi.fn()}
nestedGroupName=""
onNestedGroupNameChange={vi.fn()}
onImportNested={vi.fn()}
onCancelNested={vi.fn()}
onOpenFolder={vi.fn()}
onOpenServerFolder={vi.fn()}
onClone={vi.fn()}

View File

@ -1,8 +1,26 @@
import { ArrowRight, FolderOpen, GitBranch, Server } from 'lucide-react'
import {
ArrowLeft,
ArrowRight,
FolderOpen,
FolderTree,
GitBranch,
Lightbulb,
Server
} from 'lucide-react'
import type { Dispatch, SetStateAction } from 'react'
import { NestedRepoTreePreview } from '@/components/repo/NestedRepoTreePreview'
import type { NestedRepoScanResult } from '../../../../shared/types'
type RepoStepProps = {
cloneUrl: string
onCloneUrlChange: (value: string) => void
nestedScan: NestedRepoScanResult | null
nestedSelectedPaths: Set<string>
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
nestedGroupName: string
onNestedGroupNameChange: (value: string) => void
onImportNested: (mode: 'group' | 'separate') => void
onCancelNested: () => void
onOpenFolder: () => void
onOpenServerFolder: (kind: 'git' | 'folder') => void
onClone: () => void
@ -20,6 +38,13 @@ type RepoStepProps = {
export function RepoStep({
cloneUrl,
onCloneUrlChange,
nestedScan,
nestedSelectedPaths,
onNestedSelectedPathsChange,
nestedGroupName,
onNestedGroupNameChange,
onImportNested,
onCancelNested,
onOpenFolder,
onOpenServerFolder,
onClone,
@ -34,6 +59,91 @@ export function RepoStep({
error
}: RepoStepProps) {
const disabled = Boolean(busyLabel)
if (nestedScan) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-border bg-muted/30 p-5">
<div className="flex items-center gap-4">
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<FolderTree className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-foreground">Import as project group</div>
<div className="mt-0.5 truncate text-[13px] text-muted-foreground">
{`Found ${nestedScan.repos.length} git ${
nestedScan.repos.length === 1 ? 'repository' : 'repositories'
} in this folder.`}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{nestedScan.selectedPath}
</div>
</div>
</div>
<div className="mt-4 space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Group name</label>
<input
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15"
value={nestedGroupName}
disabled={disabled}
onChange={(event) => onNestedGroupNameChange(event.target.value)}
/>
</div>
<div className="mt-3">
<NestedRepoTreePreview
scan={nestedScan}
selectedPaths={nestedSelectedPaths}
onSelectedPathsChange={onNestedSelectedPathsChange}
disabled={disabled}
/>
</div>
{nestedScan.truncated || nestedScan.timedOut ? (
<div className="mt-2 text-[11px] text-muted-foreground">
Showing partial results from a bounded scan.
</div>
) : null}
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg px-3 py-3 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-40"
disabled={disabled}
onClick={onCancelNested}
>
<ArrowLeft className="size-3.5" />
Back
</button>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
className="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/60 disabled:opacity-40"
disabled={disabled || nestedSelectedPaths.size === 0}
onClick={() => onImportNested('separate')}
>
Import separately
</button>
<button
type="button"
className="rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
disabled={disabled || nestedSelectedPaths.size === 0 || !nestedGroupName.trim()}
onClick={() => onImportNested('group')}
>
Import as project group
</button>
</div>
</div>
</div>
{busyLabel && (
<div className="rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200">
{busyLabel}
</div>
)}
{error && (
<div className="rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
</div>
)
}
return (
<div className="space-y-3">
{runtimeActive ? (
@ -84,22 +194,30 @@ export function RepoStep({
) : (
<button
type="button"
className="group flex w-full items-center gap-4 rounded-xl border border-border bg-muted/30 p-5 text-left transition hover:border-foreground/40 hover:bg-muted/60 disabled:opacity-60"
className="group w-full rounded-xl border border-border bg-muted/30 p-5 text-left transition hover:border-foreground/40 hover:bg-muted/60 disabled:opacity-60"
disabled={disabled}
onClick={onOpenFolder}
>
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<FolderOpen className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-foreground">Open a folder</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
Choose any local directory, git repo or not.
<div className="flex items-center gap-4">
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<FolderOpen className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-foreground">Open a folder</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
Choose any local directory, git repo or not.
</div>
</div>
<span className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition group-hover:border-foreground/40">
Browse...
</span>
</div>
<div className="ml-[3.75rem] mt-3 flex items-center gap-2 rounded-lg border border-border bg-muted px-3 py-2 text-[12px] text-muted-foreground">
<span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
<Lightbulb className="size-3.5" />
</span>
<span>Want to import many repos at once? Select the parent folder.</span>
</div>
<span className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition group-hover:border-foreground/40">
Browse...
</span>
</button>
)}

View File

@ -11,7 +11,13 @@ import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types'
import type {
GlobalSettings,
NestedRepoScanResult,
OnboardingState,
Repo,
TuiAgent
} from '../../../../shared/types'
import {
DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION,
ONBOARDING_FEATURE_SETUP_IDS,
@ -42,6 +48,16 @@ type TaskSourcesGithubStatus = TaskSourcesSnapshotProps['github_status']
type TaskSourcesLinearStatus = TaskSourcesSnapshotProps['linear_status']
type TaskSourcesExitAction = TaskSourcesSnapshotProps['exit_action']
function defaultProjectGroupNameForPath(path: string): string {
return (
path
.replace(/[\\/]+$/g, '')
.split(/[\\/]/)
.filter(Boolean)
.at(-1) ?? path
)
}
function getGitHubTaskSourceStatus(
status: ReturnType<typeof useAppStore.getState>['preflightStatus'],
loading: boolean
@ -81,6 +97,8 @@ export function useOnboardingFlow(
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const addRepoPath = useAppStore((s) => s.addRepoPath)
const scanNestedRepos = useAppStore((s) => s.scanNestedRepos)
const importNestedRepos = useAppStore((s) => s.importNestedRepos)
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@ -114,6 +132,9 @@ export function useOnboardingFlow(
const [cloneUrl, setCloneUrl] = useState('')
const [serverPath, setServerPath] = useState('')
const [cloneDestination, setCloneDestination] = useState('')
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
const [nestedGroupName, setNestedGroupName] = useState('')
const [tourStarted, setTourStarted] = useState(false)
const [busyLabel, setBusyLabel] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
@ -378,10 +399,10 @@ export function useOnboardingFlow(
})
const completeRepo = useCallback(
async (repoId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => {
async (projectId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => {
await fetchRepos()
await fetchWorktrees(repoId)
const worktree = useAppStore.getState().worktreesByRepo[repoId]?.[0]
await fetchWorktrees(projectId)
const worktree = useAppStore.getState().worktreesByRepo[projectId]?.[0]
if (worktree) {
// Why: onboarding asks for a default agent immediately before this step.
// Non-git folders skip the composer, so seed their first terminal here.
@ -413,7 +434,7 @@ export function useOnboardingFlow(
})
if (isGit) {
openModal('project-added', {
repoId,
projectId,
defaultWorktreeName: 'orca-worktree-1',
telemetrySource: 'onboarding'
})
@ -533,6 +554,12 @@ export function useOnboardingFlow(
]
)
const showNestedRepoReview = useCallback((scan: NestedRepoScanResult, selectedPath: string) => {
setNestedScan(scan)
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
setNestedGroupName(defaultProjectGroupNameForPath(selectedPath))
}, [])
const startFeatureSetup = useCallback(async () => {
if (
nextInFlightRef.current ||
@ -584,8 +611,16 @@ export function useOnboardingFlow(
return
}
track('onboarding_step4_path_clicked', { path: 'open_folder' })
setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…')
setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…')
try {
if (kind === 'git') {
const scan = await scanNestedRepos(path)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
showNestedRepoReview(scan, path)
return
}
}
setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…')
const repo = await addRepoPath(path, kind)
if (!repo) {
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
@ -610,6 +645,11 @@ export function useOnboardingFlow(
try {
let result = await window.api.repos.add({ path })
if ('error' in result && result.error.includes('Not a valid git repository')) {
const scan = await scanNestedRepos(path)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
showNestedRepoReview(scan, path)
return
}
result = await window.api.repos.add({ path, kind: 'folder' })
}
if ('error' in result) {
@ -623,9 +663,73 @@ export function useOnboardingFlow(
setBusyLabel(null)
}
},
[addRepoPath, busyLabel, completeRepo, serverPath, settings?.activeRuntimeEnvironmentId]
[
addRepoPath,
busyLabel,
completeRepo,
scanNestedRepos,
serverPath,
showNestedRepoReview,
settings?.activeRuntimeEnvironmentId
]
)
const importNested = useCallback(
async (mode: 'group' | 'separate') => {
if (!nestedScan || nestedSelectedPaths.size === 0 || busyLabel !== null) {
return
}
setError(null)
setBusyLabel('Importing repositories…')
try {
const result = await importNestedRepos({
parentPath: nestedScan.selectedPath,
groupName: nestedGroupName,
projectPaths: [...nestedSelectedPaths],
mode
})
const importedRepoIds =
result?.projects
.map((entry) => entry.projectId)
.filter((projectId): projectId is string => typeof projectId === 'string') ?? []
const projectId = importedRepoIds[0]
if (!projectId) {
throw new Error('No repositories imported')
}
for (const importedRepoId of importedRepoIds) {
await fetchWorktrees(importedRepoId)
}
await completeRepo(projectId, true, 'open_folder')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
} finally {
setBusyLabel(null)
}
},
[
busyLabel,
completeRepo,
fetchWorktrees,
importNestedRepos,
nestedGroupName,
nestedScan,
nestedSelectedPaths
]
)
// Why: lets the user back out of the nested-repo step in onboarding to
// re-pick a folder/clone target. Mirrors the dialog's left-aligned Back.
const cancelNested = useCallback(() => {
if (busyLabel !== null) {
return
}
setNestedScan(null)
setNestedSelectedPaths(new Set())
setNestedGroupName('')
setError(null)
}, [busyLabel])
const clone = useCallback(async () => {
// Why: re-entry guard — prevents Enter spamming from triggering duplicate clones.
if (busyLabel !== null) {
@ -987,6 +1091,13 @@ export function useOnboardingFlow(
hasSelectedFeatureSetup,
cloneUrl,
setCloneUrl,
nestedScan,
nestedSelectedPaths,
setNestedSelectedPaths,
nestedGroupName,
setNestedGroupName,
importNested,
cancelNested,
hasExistingProject,
serverPath,
setServerPath,

View File

@ -0,0 +1,241 @@
import { useEffect, useMemo, useRef, type Dispatch, type SetStateAction } from 'react'
import { FolderTree, GitBranch } from 'lucide-react'
import type { NestedRepoCandidate, NestedRepoScanResult } from '../../../../shared/types'
type TreeFolder = {
key: string
name: string
folders: Map<string, TreeFolder>
repos: NestedRepoCandidate[]
}
type TreeRow =
| { type: 'folder'; key: string; name: string; depth: number; repoCount: number }
| { type: 'repo'; repo: NestedRepoCandidate; pathLabel: string; depth: number }
function splitPathSegments(path: string): string[] {
return path
.replace(/[\\/]+$/g, '')
.split(/[\\/]/)
.filter(Boolean)
}
function relativePathSegments(childPath: string, parentPath: string): string[] {
const child = splitPathSegments(childPath)
const parent = splitPathSegments(parentPath)
let i = 0
while (i < parent.length && i < child.length && child[i] === parent[i]) {
i++
}
return child.slice(i)
}
function countFolderRepos(folder: TreeFolder): number {
let count = folder.repos.length
for (const child of folder.folders.values()) {
count += countFolderRepos(child)
}
return count
}
function repoCountLabel(count: number): string {
return `${count} ${count === 1 ? 'repo' : 'repos'}`
}
function pathSeparatorForDisplay(path: string): string {
return path.includes('\\') ? '\\' : '/'
}
function pathLabelForRepo(repo: NestedRepoCandidate, parentPath: string): string {
return relativePathSegments(repo.path, parentPath).join(pathSeparatorForDisplay(repo.path))
}
function appendRepoRow(
rows: TreeRow[],
repo: NestedRepoCandidate,
depth: number,
parentPath: string
): void {
rows.push({
type: 'repo',
repo,
pathLabel: pathLabelForRepo(repo, parentPath),
depth
})
}
function buildRows(scan: NestedRepoScanResult): TreeRow[] {
const root: TreeFolder = { key: '', name: '', folders: new Map(), repos: [] }
for (const repo of scan.repos) {
const segments = relativePathSegments(repo.path, scan.selectedPath)
const folderSegments = segments.slice(0, -1)
let current = root
for (const segment of folderSegments) {
const key = current.key ? `${current.key}/${segment}` : segment
let child = current.folders.get(segment)
if (!child) {
child = { key, name: segment, folders: new Map(), repos: [] }
current.folders.set(segment, child)
}
current = child
}
current.repos.push(repo)
}
const rows: TreeRow[] = []
const appendFolder = (folder: TreeFolder, depth: number): void => {
rows.push({
type: 'folder',
key: folder.key,
name: folder.name,
depth,
repoCount: countFolderRepos(folder)
})
for (const repo of folder.repos) {
appendRepoRow(rows, repo, depth + 1, scan.selectedPath)
}
for (const child of folder.folders.values()) {
appendFolder(child, depth + 1)
}
}
for (const repo of root.repos) {
appendRepoRow(rows, repo, 0, scan.selectedPath)
}
for (const folder of root.folders.values()) {
appendFolder(folder, 0)
}
return rows
}
function NestedRepoSelectAllRow({
total,
selectedCount,
disabled,
onToggle
}: {
total: number
selectedCount: number
disabled: boolean
onToggle: () => void
}) {
const allSelected = total > 0 && selectedCount === total
const noneSelected = selectedCount === 0
const isMixed = !allSelected && !noneSelected
const checkboxRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = isMixed
}
}, [isMixed])
return (
<label className="flex cursor-pointer items-center gap-2.5 bg-muted/30 px-3 py-2 text-sm hover:bg-muted/50">
<input
ref={checkboxRef}
type="checkbox"
className="size-3.5"
checked={allSelected}
disabled={disabled}
onChange={onToggle}
aria-label={allSelected ? 'Deselect all' : 'Select all'}
/>
<span className="text-[12.5px] font-semibold text-foreground">
{allSelected ? 'Deselect all' : 'Select all'}
</span>
<span className="ml-auto text-[11px] text-muted-foreground">
{selectedCount} of {total} selected
</span>
</label>
)
}
export function NestedRepoTreePreview({
scan,
selectedPaths,
onSelectedPathsChange,
disabled = false
}: {
scan: NestedRepoScanResult
selectedPaths: Set<string>
onSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
disabled?: boolean
}) {
const rows = useMemo(() => buildRows(scan), [scan])
return (
<div className="overflow-hidden rounded-md border border-border bg-background/60">
<NestedRepoSelectAllRow
total={scan.repos.length}
selectedCount={selectedPaths.size}
disabled={disabled}
onToggle={() => {
onSelectedPathsChange((previous) => {
if (previous.size === scan.repos.length) {
return new Set()
}
return new Set(scan.repos.map((repo) => repo.path))
})
}}
/>
<ul className="scrollbar-sleek max-h-64 overflow-y-auto">
{rows.map((row) =>
row.type === 'folder' ? (
<li
key={`folder:${row.key}`}
className="flex items-center gap-2.5 border-t border-border bg-muted/20 px-3 py-2 text-sm"
style={{ paddingLeft: 12 + row.depth * 18 }}
>
<FolderTree className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
{row.name}
</span>
<span className="shrink-0 rounded-full border border-border bg-background px-2 py-0.5 text-[11px] leading-none font-medium text-muted-foreground">
Project group
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{repoCountLabel(row.repoCount)}
</span>
</li>
) : (
<li key={row.repo.path}>
<label
className="flex cursor-pointer items-center gap-2.5 border-t border-border px-3 py-2 text-sm hover:bg-accent"
style={{ paddingLeft: 12 + row.depth * 18 }}
>
<input
type="checkbox"
className="size-3.5"
checked={selectedPaths.has(row.repo.path)}
disabled={disabled}
onChange={(event) => {
onSelectedPathsChange((previous) => {
const next = new Set(previous)
if (event.target.checked) {
next.add(row.repo.path)
} else {
next.delete(row.repo.path)
}
return next
})
}}
/>
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
<span
className={`min-w-0 flex-1 truncate text-[13px] font-medium ${
selectedPaths.has(row.repo.path) ? 'text-foreground' : 'text-muted-foreground'
}`}
>
{row.repo.displayName}
</span>
<span className="ml-auto min-w-0 max-w-[52%] truncate text-right font-mono text-[11px] text-muted-foreground">
{row.pathLabel}
</span>
</label>
</li>
)
)}
</ul>
</div>
)
}

View File

@ -27,7 +27,7 @@ type RepositoryPaneProps = {
hooksInspectionReady: boolean
mayNeedUpdate: boolean
updateRepo: (repoId: string, updates: Partial<Repo>) => void
removeRepo: (repoId: string) => void
removeProject: (repoId: string) => void
}
export function RepositoryPane({
@ -37,7 +37,7 @@ export function RepositoryPane({
hooksInspectionReady,
mayNeedUpdate,
updateRepo,
removeRepo
removeProject
}: RepositoryPaneProps): React.JSX.Element {
const isFolder = isFolderRepo(repo)
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
@ -45,9 +45,9 @@ export function RepositoryPane({
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [copiedTemplate, setCopiedTemplate] = useState(false)
const handleRemoveRepo = (repoId: string) => {
const handleRemoveProject = (repoId: string) => {
if (confirmingRemove === repoId) {
removeRepo(repoId)
removeProject(repoId)
setConfirmingRemove(null)
return
}
@ -139,7 +139,7 @@ export function RepositoryPane({
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleRemoveRepo(repo.id)}
onClick={() => handleRemoveProject(repo.id)}
onBlur={() => setConfirmingRemove(null)}
className="gap-2"
>

View File

@ -150,7 +150,7 @@ function Settings(): React.JSX.Element {
const closeSettingsPage = useAppStore((s) => s.closeSettingsPage)
const repos = useAppStore((s) => s.repos)
const updateRepo = useAppStore((s) => s.updateRepo)
const removeRepo = useAppStore((s) => s.removeRepo)
const removeProject = useAppStore((s) => s.removeProject)
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
@ -1100,7 +1100,7 @@ function Settings(): React.JSX.Element {
hooksInspectionReady={Boolean(repoHooksState)}
mayNeedUpdate={repoHooksState?.mayNeedUpdate ?? false}
updateRepo={updateRepo}
removeRepo={removeRepo}
removeProject={removeProject}
/>
) : null}
</SettingsSection>

View File

@ -18,7 +18,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'setup'
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'
type RepoKind = 'git' | 'folder'
export function useCreateRepo(

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: the add-project dialog centralizes step routing, clone/remote/create state, and reset semantics across five steps so the modal flow stays in one place. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FolderOpen, ArrowLeft, Globe, Monitor } from 'lucide-react'
import { FolderOpen, ArrowLeft, Globe, Monitor, FolderTree, Lightbulb } from 'lucide-react'
import { useAppStore } from '@/store'
import {
Dialog,
@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { NestedRepoTreePreview } from '@/components/repo/NestedRepoTreePreview'
import { track } from '@/lib/telemetry'
import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps'
import { CreateStep, useCreateRepo } from './AddRepoCreateStep'
@ -24,7 +25,7 @@ import type {
AddRepoExistingWorkspaceSource,
AddRepoSetupStepAction
} from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
import {
buildAddRepoExistingWorkspacesTelemetry,
@ -35,11 +36,22 @@ import {
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
function defaultProjectGroupNameForPath(path: string): string {
return (
path
.replace(/[\\/]+$/g, '')
.split(/[\\/]/)
.filter(Boolean)
.at(-1) ?? path
)
}
const AddRepoDialog = React.memo(function AddRepoDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
const addRepo = useAppStore((s) => s.addRepo)
const addRepoPath = useAppStore((s) => s.addRepoPath)
const scanNestedRepos = useAppStore((s) => s.scanNestedRepos)
const importNestedRepos = useAppStore((s) => s.importNestedRepos)
const updateRepo = useAppStore((s) => s.updateRepo)
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
@ -51,7 +63,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const settings = useAppStore((s) => s.settings)
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'>(
'add'
)
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
const [existingWorkspaceSource, setExistingWorkspaceSource] =
useState<AddRepoExistingWorkspaceSource | null>(null)
@ -65,6 +79,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>(
null
)
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
const [nestedGroupName, setNestedGroupName] = useState('')
const [nestedConnectionId, setNestedConnectionId] = useState<string | null>(null)
// Why: monotonic ID so stale clone callbacks can detect they were superseded.
const cloneGenRef = useRef(0)
@ -85,7 +103,21 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
handleOpenRemoteStep,
handleAddRemoteRepo,
handleConnectTarget
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal, setExistingWorkspaceSource)
} = useRemoteRepo(
fetchWorktrees,
setStep,
setAddedRepo,
closeModal,
setExistingWorkspaceSource,
scanNestedRepos,
(scan, selectedPath, connectionId) => {
setNestedScan(scan)
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
setNestedGroupName(defaultProjectGroupNameForPath(scan.selectedPath || selectedPath))
setNestedConnectionId(connectionId)
setStep('nested')
}
)
const {
createName,
@ -130,13 +162,13 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir])
const isOpen = activeModal === 'add-repo'
const repoId = addedRepo?.id ?? ''
const projectId = addedRepo?.id ?? ''
const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const worktrees = useMemo(() => {
return worktreesByRepo[repoId] ?? []
}, [worktreesByRepo, repoId])
const detectedResult = repoId ? detectedWorktreesByRepo[repoId] : undefined
return worktreesByRepo[projectId] ?? []
}, [worktreesByRepo, projectId])
const detectedResult = projectId ? detectedWorktreesByRepo[projectId] : undefined
const hiddenWorktreeCount =
detectedResult?.authoritative === true
? detectedResult.worktrees.filter(
@ -181,6 +213,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setIsCloning(false)
setCloneError(null)
setCloneProgress(null)
setNestedScan(null)
setNestedSelectedPaths(new Set())
setNestedGroupName('')
setNestedConnectionId(null)
resetCreateState()
resetRemoteState()
}, [resetRemoteState, resetCreateState])
@ -192,12 +228,30 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}
}, [isOpen, resetState])
const isInputStep = step === 'add' || step === 'clone' || step === 'remote' || step === 'create'
const isInputStep =
step === 'add' ||
step === 'clone' ||
step === 'remote' ||
step === 'create' ||
step === 'nested'
const handleBrowse = useCallback(async () => {
setIsAdding(true)
try {
const repo = await addRepo()
const path = await window.api.repos.pickFolder()
if (!path) {
return
}
const scan = await scanNestedRepos(path)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
setNestedScan(scan)
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
setNestedGroupName(defaultProjectGroupNameForPath(path))
setNestedConnectionId(null)
setStep('nested')
return
}
const repo = await addRepoPath(path)
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
setExistingWorkspaceSource('local_folder_picker')
@ -211,7 +265,67 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
} finally {
setIsAdding(false)
}
}, [addRepo, fetchWorktrees, closeModal])
}, [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos])
const handleImportNestedRepos = useCallback(
async (mode: 'group' | 'separate') => {
if (!nestedScan || nestedSelectedPaths.size === 0) {
return
}
setIsAdding(true)
try {
const result = await importNestedRepos({
parentPath: nestedScan.selectedPath,
groupName: nestedGroupName,
projectPaths: [...nestedSelectedPaths],
...(nestedConnectionId ? { connectionId: nestedConnectionId } : {}),
mode
})
if (!result) {
return
}
const importedRepoIds = result.projects
.map((entry) => entry.projectId)
.filter((projectId): projectId is string => typeof projectId === 'string')
const firstRepoId = importedRepoIds[0]
if (!firstRepoId) {
toast.error('No repositories imported')
return
}
for (const projectId of importedRepoIds) {
await fetchWorktrees(projectId)
}
const repo = useAppStore.getState().repos.find((entry) => entry.id === firstRepoId)
if (repo) {
setAddedRepo(repo)
setExistingWorkspaceSource(
nestedConnectionId
? 'ssh_remote_path'
: settings?.activeRuntimeEnvironmentId?.trim()
? 'runtime_server_path'
: 'local_folder_picker'
)
setStep('setup')
}
if (result.failedCount > 0) {
toast.warning('Some repositories could not be imported', {
description: `${result.failedCount} failed`
})
}
} finally {
setIsAdding(false)
}
},
[
fetchWorktrees,
importNestedRepos,
nestedGroupName,
nestedScan,
nestedSelectedPaths,
nestedConnectionId,
settings?.activeRuntimeEnvironmentId
]
)
const handleAddServerPath = useCallback(
async (kind: 'git' | 'folder') => {
@ -221,6 +335,17 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}
setIsAddingServerPath(true)
try {
if (kind === 'git') {
const scan = await scanNestedRepos(path)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
setNestedScan(scan)
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
setNestedGroupName(defaultProjectGroupNameForPath(path))
setNestedConnectionId(null)
setStep('nested')
return
}
}
const repo = await addRepoPath(path, kind)
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
@ -236,7 +361,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setIsAddingServerPath(false)
}
},
[addRepoPath, closeModal, fetchWorktrees, serverPath]
[addRepoPath, closeModal, fetchWorktrees, scanNestedRepos, serverPath]
)
const handlePickDestination = useCallback(async () => {
@ -323,16 +448,16 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
useEffect(() => {
if (
step !== 'setup' ||
!repoId ||
!projectId ||
!existingWorkspaceTelemetry ||
!shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) ||
detectedTelemetryTrackedRef.current.has(repoId)
detectedTelemetryTrackedRef.current.has(projectId)
) {
return
}
detectedTelemetryTrackedRef.current.add(repoId)
detectedTelemetryTrackedRef.current.add(projectId)
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
}, [existingWorkspaceSource, existingWorkspaceTelemetry, repoId, step])
}, [existingWorkspaceSource, existingWorkspaceTelemetry, projectId, step])
const trackSetupAction = useCallback(
(action: AddRepoSetupStepAction): void => {
@ -361,13 +486,13 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
closeModal()
setTimeout(() => {
openModal('new-workspace-composer', {
initialRepoId: repoId,
initialRepoId: projectId,
...(name ? { prefilledName: name } : {}),
telemetrySource: 'sidebar'
})
}, 150)
},
[closeModal, openModal, repoId, trackSetupAction]
[closeModal, openModal, projectId, trackSetupAction]
)
const handleStartPrimaryWorktree = useCallback(() => {
@ -385,12 +510,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const handleConfigureRepo = useCallback(() => {
trackSetupAction('configure')
closeModal()
openSettingsTarget({ pane: 'repo', repoId })
openSettingsTarget({ pane: 'repo', repoId: projectId })
openSettingsPage()
}, [closeModal, openSettingsTarget, openSettingsPage, repoId, trackSetupAction])
}, [closeModal, openSettingsTarget, openSettingsPage, projectId, trackSetupAction])
const finishImportedRepoWithoutOpening = useCallback(async () => {
const importedRepoId = repoId
const importedRepoId = projectId
closeModal()
resetState()
if (!importedRepoId) {
@ -400,19 +525,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
await fetchWorktrees(importedRepoId)
const state = useAppStore.getState()
finalizeImportedRepoAfterSkip(state, importedRepoId)
}, [closeModal, fetchWorktrees, repoId, resetState])
}, [closeModal, fetchWorktrees, projectId, resetState])
const handleUseExistingWorktrees = useCallback(async () => {
if (!repoId) {
if (!projectId) {
return
}
trackSetupAction('open_existing')
if (!otherWorktreesVisible) {
const updated = await updateRepo(repoId, { externalWorktreeVisibility: 'show' })
const updated = await updateRepo(projectId, { externalWorktreeVisibility: 'show' })
if (updated && addedRepo) {
setAddedRepo({ ...addedRepo, externalWorktreeVisibility: 'show' })
}
await fetchWorktrees(repoId)
await fetchWorktrees(projectId)
}
await finishImportedRepoWithoutOpening()
}, [
@ -420,7 +545,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
fetchWorktrees,
finishImportedRepoWithoutOpening,
otherWorktreesVisible,
repoId,
projectId,
trackSetupAction,
updateRepo
])
@ -468,6 +593,16 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
Back
</button>
)}
{step === 'nested' && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:cursor-default disabled:opacity-40"
disabled={isAdding}
onClick={handleBack}
>
<ArrowLeft className="size-3" />
Back
</button>
)}
{step === 'setup' && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
@ -612,6 +747,13 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
</Button>
</div>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
<span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
<Lightbulb className="size-3.5" />
</span>
<span>Want to import many repos at once? Select the parent folder.</span>
</div>
{/* Secondary link rather than a fourth card create-from-scratch
is a less common path than importing. See orca#763. */}
<div className="flex items-center justify-center pt-1">
@ -669,6 +811,75 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
onPickDestination={handlePickDestination}
onClone={handleClone}
/>
) : step === 'nested' && nestedScan ? (
<>
<DialogHeader>
<DialogTitle>Import as project group</DialogTitle>
<DialogDescription>
{`Found ${nestedScan.repos.length} git ${
nestedScan.repos.length === 1 ? 'repository' : 'repositories'
} in this folder.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-1">
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/30 p-3">
<div className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<FolderTree className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
Group under {nestedGroupName}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{nestedScan.selectedPath}
</div>
</div>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">Group name</label>
<Input
value={nestedGroupName}
onChange={(event) => setNestedGroupName(event.target.value)}
className="h-9"
/>
</div>
<NestedRepoTreePreview
scan={nestedScan}
selectedPaths={nestedSelectedPaths}
onSelectedPathsChange={setNestedSelectedPaths}
disabled={isAdding}
/>
{nestedScan.truncated || nestedScan.timedOut ? (
<div className="text-[11px] text-muted-foreground">
Showing partial results from a bounded scan.
</div>
) : null}
<div className="flex items-center gap-2">
<Button onClick={handleBack} disabled={isAdding} variant="ghost">
<ArrowLeft className="size-3.5" />
Back
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
onClick={() => void handleImportNestedRepos('separate')}
disabled={isAdding || nestedSelectedPaths.size === 0}
variant="outline"
>
Import separately
</Button>
<Button
onClick={() => void handleImportNestedRepos('group')}
disabled={isAdding || nestedSelectedPaths.size === 0 || !nestedGroupName.trim()}
>
Import as project group
</Button>
</div>
</div>
</div>
</>
) : step === 'create' ? (
<CreateStep
createName={createName}

View File

@ -15,17 +15,23 @@ import { Input } from '@/components/ui/input'
import { RemoteFileBrowser } from './RemoteFileBrowser'
import { SshTargetRow } from './SshTargetRow'
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
import type { Repo } from '../../../../shared/types'
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types'
// ── Remote project hook ─────────────────────────────────────────────
export function useRemoteRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'setup') => void,
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup') => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void,
setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void
setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void,
scanNestedRepos?: (path: string, connectionId?: string) => Promise<NestedRepoScanResult | null>,
showNestedRepoReview?: (
scan: NestedRepoScanResult,
selectedPath: string,
connectionId: string
) => void
) {
const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([])
const [selectedTargetId, setSelectedTargetId] = useState<string | null>(null)
@ -101,12 +107,18 @@ export function useRemoteRepo(
return
}
const trimmedRemotePath = remotePath.trim()
setIsAddingRemote(true)
setRemoteError(null)
try {
const scan = await scanNestedRepos?.(trimmedRemotePath, selectedTargetId)
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId)
return
}
const result = await window.api.repos.addRemote({
connectionId: selectedTargetId,
remotePath: remotePath.trim()
remotePath: trimmedRemotePath
})
if ('error' in result) {
throw new Error(result.error)
@ -139,7 +151,7 @@ export function useRemoteRepo(
// silently adding as a folder.
closeModal()
useAppStore.getState().openModal('confirm-non-git-folder', {
folderPath: remotePath.trim(),
folderPath: trimmedRemotePath,
connectionId: selectedTargetId
})
return
@ -151,6 +163,8 @@ export function useRemoteRepo(
}, [
selectedTargetId,
remotePath,
scanNestedRepos,
showNestedRepoReview,
fetchWorktrees,
setStep,
setAddedRepo,

View File

@ -28,7 +28,6 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
void (async () => {
try {
const stateBeforeAdd = useAppStore.getState()
const hadProjectBeforeAdd = stateBeforeAdd.repos.length > 0
const result = await window.api.repos.addRemote({
connectionId,
remotePath: folderPath,
@ -39,6 +38,7 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
}
const repo = result.repo
const state = useAppStore.getState()
const hadProjectBeforeAdd = stateBeforeAdd.repos.length > 0
if (!state.repos.some((r) => r.id === repo.id)) {
useAppStore.setState({ repos: [...state.repos, repo] })
}

View File

@ -0,0 +1,90 @@
import React, { useCallback, useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type ProjectGroupDeleteDialogProps = {
open: boolean
groupName: string
onOpenChange: (open: boolean) => void
onConfirm: () => Promise<void> | void
}
export function ProjectGroupDeleteDialog({
open,
groupName,
onOpenChange,
onConfirm
}: ProjectGroupDeleteDialogProps): React.JSX.Element {
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (open) {
setDeleting(false)
}
}, [open])
const handleConfirm = useCallback(async () => {
if (deleting) {
return
}
setDeleting(true)
try {
await onConfirm()
setDeleting(false)
onOpenChange(false)
} catch (error) {
console.error('Failed to delete project group:', error)
setDeleting(false)
}
}, [deleting, onConfirm, onOpenChange])
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDeleting(false)
}
onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">Delete Project Group</DialogTitle>
<DialogDescription className="text-xs">
Delete <span className="break-all font-medium text-foreground">{groupName}</span> and
ungroup its projects.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="text-xs"
disabled={deleting}
onClick={handleConfirm}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type ProjectGroupNameDialogProps = {
open: boolean
title: string
description: string
initialName: string
confirmLabel: string
onOpenChange: (open: boolean) => void
onSubmit: (name: string) => Promise<void> | void
}
export function ProjectGroupNameDialog({
open,
title,
description,
initialName,
confirmLabel,
onOpenChange,
onSubmit
}: ProjectGroupNameDialogProps): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
const inputId = useId()
const [name, setName] = useState(initialName)
const [submitting, setSubmitting] = useState(false)
const trimmedName = name.trim()
useEffect(() => {
if (open) {
setName(initialName)
setSubmitting(false)
}
}, [initialName, open])
const handleSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault()
if (!trimmedName || submitting) {
return
}
setSubmitting(true)
try {
await onSubmit(trimmedName)
onOpenChange(false)
} catch (error) {
console.error('Failed to save project group name:', error)
setSubmitting(false)
}
},
[onOpenChange, onSubmit, submitting, trimmedName]
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-sm sm:max-w-sm"
onOpenAutoFocus={(event) => {
event.preventDefault()
inputRef.current?.focus()
inputRef.current?.select()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<Label htmlFor={inputId} className="text-[11px] text-muted-foreground">
Group Name
</Label>
<Input
id={inputId}
ref={inputRef}
value={name}
onChange={(event) => setName(event.target.value)}
className="h-8 text-xs"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="text-xs"
disabled={!trimmedName || submitting}
>
{submitting ? 'Saving...' : confirmLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -14,7 +14,7 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const removeRepo = useAppStore((s) => s.removeRepo)
const removeProject = useAppStore((s) => s.removeProject)
const isOpen = activeModal === 'confirm-remove-folder'
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
@ -22,10 +22,10 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
const handleConfirm = useCallback(() => {
if (repoId) {
void removeRepo(repoId)
void removeProject(repoId)
}
closeModal()
}, [closeModal, removeRepo, repoId])
}, [closeModal, removeProject, repoId])
const handleOpenChange = useCallback(
(open: boolean) => {

View File

@ -79,7 +79,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
[filterRepoIds, setFilterRepoIds]
)
const handleRemoveRepo = useCallback(
const handleRemoveProject = useCallback(
(repoId: string) => {
setFilterRepoIds(filterRepoIds.filter((id) => id !== repoId))
},
@ -97,7 +97,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
if (lastRepo) {
event.preventDefault()
event.stopPropagation()
handleRemoveRepo(lastRepo.id)
handleRemoveProject(lastRepo.id)
}
return
}
@ -119,7 +119,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
},
[
availableRepos,
handleRemoveRepo,
handleRemoveProject,
handleSelectRepo,
highlightedRepoId,
matchingAvailableRepos,
@ -145,7 +145,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
onValueChange={setHighlightedRepoId}
className="bg-transparent"
>
<SelectedProjectPills selectedRepos={selectedRepos} onRemoveRepo={handleRemoveRepo} />
<SelectedProjectPills selectedRepos={selectedRepos} onRemoveProject={handleRemoveProject} />
<CommandInput
autoFocus
placeholder={selectedRepos.length > 0 ? 'Add project...' : 'Filter projects...'}
@ -191,10 +191,10 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
function SelectedProjectPills({
selectedRepos,
onRemoveRepo
onRemoveProject
}: {
selectedRepos: Repo[]
onRemoveRepo: (repoId: string) => void
onRemoveProject: (repoId: string) => void
}) {
if (selectedRepos.length === 0) {
return null
@ -221,7 +221,7 @@ function SelectedProjectPills({
aria-label={`Remove ${repo.displayName} filter`}
className="-mr-1 size-4 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onRemoveRepo(repo.id)}
onClick={() => onRemoveProject(repo.id)}
>
<X className="size-2.5" strokeWidth={2.5} />
</Button>

View File

@ -17,6 +17,7 @@ import {
Copy,
Bell,
BellOff,
CircleX,
Moon,
Pencil,
Pin,
@ -24,7 +25,9 @@ import {
Kanban,
Trash2,
Unlink,
Workflow
Workflow,
FolderInput,
FolderPlus
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
@ -39,6 +42,7 @@ import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedSc
import { getLineageRenderInfo } from './worktree-list-groups'
import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
type Props = {
worktree: Worktree
@ -188,11 +192,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const openModal = useAppStore((s) => s.openModal)
const projectGroups = useAppStore((s) => s.projectGroups)
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
const repo = useRepoById(worktree.repoId)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const [contextWorktrees, setContextWorktrees] = useState<readonly Worktree[]>(selectedWorktrees)
const [createGroupDialogOpen, setCreateGroupDialogOpen] = useState(false)
const isDeleting = deleteState?.isDeleting ?? false
const isFolder = repo ? isFolderRepo(repo) : false
const repoMap = useRepoMap()
@ -281,6 +289,43 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
updateWorktreeMeta(worktree.id, { isPinned: !worktree.isPinned })
}, [worktree.id, worktree.isPinned, updateWorktreeMeta])
const handleCreateGroupFromRepo = useCallback(() => {
if (!repo) {
return
}
setCreateGroupDialogOpen(true)
}, [repo])
const handleSubmitNewProjectGroup = useCallback(
async (name: string) => {
if (!repo) {
return
}
const group = await createProjectGroup(name)
if (group) {
await moveProjectToGroup(repo.id, group.id)
}
},
[createProjectGroup, moveProjectToGroup, repo]
)
const handleMoveProjectToGroup = useCallback(
(groupId: string) => {
if (!repo || repo.projectGroupId === groupId) {
return
}
void moveProjectToGroup(repo.id, groupId)
},
[moveProjectToGroup, repo]
)
const handleRemoveProjectFromGroup = useCallback(() => {
if (!repo) {
return
}
void moveProjectToGroup(repo.id, null)
}, [moveProjectToGroup, repo])
const handleAssignWorkspaceStatus = useCallback(
(status: string) => {
setMenuOpenState(false)
@ -480,6 +525,40 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
)}
{worktree.isUnread ? 'Mark Read' : 'Mark Unread'}
</DropdownMenuItem>
{repo ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleCreateGroupFromRepo} disabled={isDeleting}>
<FolderPlus className="size-3.5" />
New group from project
</DropdownMenuItem>
{projectGroups.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isDeleting}>
<FolderInput className="size-3.5" />
Move to group
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{projectGroups.map((group) => (
<DropdownMenuItem
key={group.id}
disabled={repo.projectGroupId === group.id}
onSelect={() => handleMoveProjectToGroup(group.id)}
>
<span className="max-w-48 truncate">{group.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
{repo.projectGroupId ? (
<DropdownMenuItem onSelect={handleRemoveProjectFromGroup} disabled={isDeleting}>
<CircleX className="size-3.5" />
Remove from group
</DropdownMenuItem>
) : null}
</>
) : null}
<DropdownMenuSeparator />
{(validParentWorktreeId || lineage) && (
<>
@ -586,6 +665,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ProjectGroupNameDialog
open={createGroupDialogOpen}
title="New Project Group"
description="Create a group and move this project into it."
initialName={repo ? `${repo.displayName} group` : ''}
confirmLabel="Create"
onOpenChange={setCreateGroupDialogOpen}
onSubmit={handleSubmitNewProjectGroup}
/>
</div>
)
})

View File

@ -41,7 +41,7 @@ vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({
useVirtualizedScrollAnchor: vi.fn()
}))
vi.mock('./repo-header-drag', () => ({
vi.mock('./project-header-drag', () => ({
useRepoHeaderDrag: () => ({
state: { draggingRepoId: null, dropIndicatorY: null },
onHandlePointerDown: vi.fn()

View File

@ -11,6 +11,8 @@ import {
CircleX,
Ellipsis,
Eye,
FolderInput,
FolderPlus,
Plus,
Shapes,
SlidersHorizontal,
@ -35,12 +37,16 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import type {
Worktree,
Repo,
ProjectGroup,
WorktreeLineage,
WorkspaceStatus,
WorkspaceStatusDefinition
@ -56,14 +62,14 @@ import { track } from '@/lib/telemetry'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import {
type GroupHeaderRow,
type RepoGroupOrdering,
type ProjectGroupOrdering,
type Row,
type WorktreeGroupBy,
ALL_GROUP_KEY,
PINNED_GROUP_KEY,
buildRows,
getGroupKeyForWorktree,
getRepoGroupOrdering,
getGroupKeysForWorktree,
getProjectGroupOrdering,
getLineageGroupKey
} from './worktree-list-groups'
import {
@ -101,7 +107,7 @@ import {
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT } from '@/lib/scroll-to-current-workspace-status'
import { useRepoHeaderDrag } from './repo-header-drag'
import { useRepoHeaderDrag } from './project-header-drag'
import WorktreeContextMenu from './WorktreeContextMenu'
import {
buildWorktreeDragPreviewOffsets,
@ -119,7 +125,7 @@ import {
setSidebarPointerDragDocumentStyles,
updateSidebarDragPreviewPosition
} from './worktree-sidebar-pointer-drag-dom'
import { resolveRepoGroupHeaderColor } from './repo-header-color'
import { resolveProjectGroupHeaderColor } from './project-header-color'
import {
areWorktreeSelectionsEqual,
getWorktreeSelectionIntent,
@ -132,6 +138,8 @@ import { getRepoHeaderCreateState } from './repo-header-create-state'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
effectiveExternalWorktreeVisibility,
@ -140,6 +148,15 @@ import {
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
type ProjectGroupNameDialogState =
| { type: 'create-from-repo'; repo: Repo }
| { type: 'rename'; groupId: string; currentName: string }
type ProjectGroupDeleteDialogState = {
groupId: string
groupName: string
}
// How long to wait after a sortEpoch bump before actually re-sorting.
// Prevents jarring position shifts when background events (AI starting work,
// terminal title changes) trigger score recalculations.
@ -277,6 +294,7 @@ const LINEAGE_INDENT = 18
// Why: top-level worktrees are children of their project header; indent the
// group one step so the status dots nest under the folder icon for hierarchy.
const WORKTREE_GROUP_INDENT = 18
const PROJECT_GROUP_HEADER_INDENT = 10
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
type VirtualizedWorktreeViewportProps = {
@ -284,13 +302,18 @@ type VirtualizedWorktreeViewportProps = {
activeWorktreeId: string | null
currentWorktreeId: string | null
groupBy: WorktreeGroupBy
repoGroupOrdering: RepoGroupOrdering
projectGroupOrdering: ProjectGroupOrdering
toggleGroup: (key: string) => void
collapsedGroups: Set<string>
handleCreateForRepo: (repoId: string) => void
handleOpenRepoSettings: (repoId: string, sectionId?: string) => void
handleOpenWorktreeVisibility: (repoId: string) => void
handleRemoveRepo: (repo: Repo) => void
handleCreateForRepo: (projectId: string) => void
handleOpenRepoSettings: (projectId: string, sectionId?: string) => void
handleOpenWorktreeVisibility: (projectId: string) => void
handleRemoveProject: (repo: Repo) => void
handleCreateGroupFromRepo: (repo: Repo) => void
handleMoveProjectToGroup: (repo: Repo, groupId: string) => void
handleRemoveProjectFromGroup: (repo: Repo) => void
handleRenameProjectGroup: (groupId: string, currentName: string) => void
handleDeleteProjectGroup: (groupId: string, groupName: string) => void
activeModal: string
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
clearPendingRevealWorktreeId: () => void
@ -314,6 +337,7 @@ type VirtualizedWorktreeViewportProps = {
reorderRepos: (orderedIds: string[]) => void
prCache: Record<string, unknown> | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
projectGroups?: readonly ProjectGroup[]
onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void
onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onPinWorktree: (worktreeId: string) => void
@ -583,13 +607,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeWorktreeId,
currentWorktreeId,
groupBy,
repoGroupOrdering,
projectGroupOrdering,
toggleGroup,
collapsedGroups,
handleCreateForRepo,
handleOpenRepoSettings,
handleOpenWorktreeVisibility,
handleRemoveRepo,
handleRemoveProject,
handleCreateGroupFromRepo,
handleMoveProjectToGroup,
handleRemoveProjectFromGroup,
handleRenameProjectGroup,
handleDeleteProjectGroup,
activeModal,
pendingRevealWorktree,
clearPendingRevealWorktreeId,
@ -606,6 +635,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
reorderRepos,
prCache,
workspaceStatuses,
projectGroups = [],
onMoveWorktreeToStatus,
onMoveWorktreesToStatus,
onPinWorktree,
@ -649,7 +679,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
})
}, [])
const suppressWorktreeClickUntilRef = useRef(0)
const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
const hasProjectGroups = projectGroups.length > 0
const canReorderRepoHeaders =
groupBy === 'repo' && projectGroupOrdering === 'manual' && !hasProjectGroups
const lastVisibleRefreshKeyRef = useRef('')
const reportVisibleGitHubPRRefreshCandidates = useAppStore(
(s) => s.reportVisibleGitHubPRRefreshCandidates
@ -979,23 +1011,26 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (targetWorktree?.isPinned) {
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
// of their PR-status / repo group. Only uncollapse the Pinned header
// of their PR-status / project group. Only uncollapse the Pinned header
// itself — expanding the underlying status group would be surprising since
// the user intentionally collapsed it.
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
toggleGroup(PINNED_GROUP_KEY)
}
} else if (targetWorktree) {
const groupKey = getGroupKeyForWorktree(
const groupKeys = getGroupKeysForWorktree(
groupBy,
targetWorktree,
repoMap,
prCache,
workspaceStatuses,
settings
settings,
projectGroups
)
if (groupKey && collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
for (const groupKey of groupKeys) {
if (collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
}
}
}
}
@ -1086,6 +1121,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
collapsedGroups,
workspaceStatuses,
settings,
projectGroups,
pendingRevealRetryTick,
flashRevealedWorktree
])
@ -1180,11 +1216,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
new Set<string>(),
repoOrder,
workspaceStatuses,
repoGroupOrdering,
projectGroupOrdering,
worktreeLineageById,
worktreeMap,
true,
settings
settings,
projectGroups
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
if (worktreeRows.length === 0) {
return
@ -1222,7 +1259,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeWorktreeId,
virtualizer,
groupBy,
repoGroupOrdering,
projectGroupOrdering,
worktrees,
repoMap,
prCache,
@ -1230,7 +1267,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
workspaceStatuses,
worktreeLineageById,
worktreeMap,
settings
settings,
projectGroups
]
)
@ -1946,17 +1984,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
firstHeaderIndex
})
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined
const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined
const isDraggingThis =
canReorderRepoHeaders &&
repoDrag.state.draggingRepoId !== null &&
repoDrag.state.draggingRepoId === repoIdForHeader
repoDrag.state.draggingRepoId === projectIdForHeader
const headerWorkspaceStatus =
groupBy === 'workspace-status'
? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses)
: null
const isPinnedHeader = row.key === PINNED_GROUP_KEY
const repoHeaderColor = resolveRepoGroupHeaderColor({
const repoHeaderColor = resolveProjectGroupHeaderColor({
groupBy,
headerKey: row.key,
badgeColor: row.repo?.badgeColor
@ -1970,6 +2009,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
: null
})
: null
const projectGroupDepth = row.projectGroupDepth ?? 0
return (
<div
key={vItem.key}
@ -1999,12 +2039,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<div
role="button"
tabIndex={0}
data-repo-header-id={repoIdForHeader}
data-repo-header-id={projectIdForHeader}
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
data-workspace-status={headerWorkspaceStatus ?? undefined}
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
className={cn(
'group flex h-7 w-full items-center gap-1.5 pl-3 pr-1 text-left transition-all',
'group flex h-7 w-full items-center gap-1.5 pr-1 text-left transition-all',
'cursor-pointer',
isDraggingThis &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
@ -2016,6 +2056,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
'rounded-md bg-sidebar-accent ring-1 ring-sidebar-ring/40',
row.repo && 'overflow-hidden'
)}
style={{
paddingLeft: 12 + Math.min(projectGroupDepth, 6) * PROJECT_GROUP_HEADER_INDENT
}}
onDragOver={
isPinnedHeader
? handleWorkspacePinDragOver
@ -2046,8 +2089,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.icon ? (
<div
onPointerDown={
canReorderRepoHeaders && isRepoHeader && repoIdForHeader
? (e) => repoDrag.onHandlePointerDown(e, repoIdForHeader)
canReorderRepoHeaders && isRepoHeader && projectIdForHeader
? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader)
: undefined
}
className={cn(
@ -2088,6 +2131,51 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
/>
</div>
{isProjectGroupHeader && !row.repo && row.projectGroup?.id ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Group actions for ${row.label}`}
onClick={(event) => event.stopPropagation()}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={(event) => event.stopPropagation()}
>
<Ellipsis className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
sideOffset={6}
onClick={(event) => event.stopPropagation()}
>
<DropdownMenuItem
onSelect={() => {
if (row.projectGroup?.id) {
handleRenameProjectGroup(row.projectGroup.id, row.label)
}
}}
>
Rename group
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
if (row.projectGroup?.id) {
handleDeleteProjectGroup(row.projectGroup.id, row.label)
}
}}
>
Delete group
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{row.repo && groupBy === 'repo' ? (
<DropdownMenu modal={false}>
<Tooltip>
@ -2152,12 +2240,57 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{getWorktreeVisibilityMenuLabel(row.repo)}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onSelect={() => {
if (row.repo) {
handleCreateGroupFromRepo(row.repo)
}
}}
>
<FolderPlus className="size-3.5" />
New group from project
</DropdownMenuItem>
{projectGroups.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="size-3.5" />
Move to group
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{projectGroups.map((group) => (
<DropdownMenuItem
key={group.id}
disabled={row.repo?.projectGroupId === group.id}
onSelect={() => {
if (row.repo) {
handleMoveProjectToGroup(row.repo, group.id)
}
}}
>
<span className="max-w-48 truncate">{group.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
{row.repo.projectGroupId ? (
<DropdownMenuItem
onSelect={() => {
if (row.repo) {
handleRemoveProjectFromGroup(row.repo)
}
}}
>
<CircleX className="size-3.5" />
Remove from group
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
if (row.repo) {
handleRemoveRepo(row.repo)
handleRemoveProject(row.repo)
}
}}
>
@ -2861,6 +2994,7 @@ const WorktreeList = React.memo(function WorktreeList({
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
const projectGroups = useAppStore((s) => s.projectGroups)
const repoOrder = useMemo(() => {
const map = new Map<string, number>()
repos.forEach((r, i) => map.set(r.id, i))
@ -2868,7 +3002,7 @@ const WorktreeList = React.memo(function WorktreeList({
}, [repos])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
const reorderReposAction = useAppStore((s) => s.reorderRepos)
const repoGroupOrdering = getRepoGroupOrdering(groupBy, sortBy)
const projectGroupOrdering = getProjectGroupOrdering(groupBy, sortBy)
// Build flat row list for rendering
const rows: Row[] = useMemo(
@ -2881,11 +3015,12 @@ const WorktreeList = React.memo(function WorktreeList({
collapsedGroups,
repoOrder,
workspaceStatuses,
repoGroupOrdering,
projectGroupOrdering,
worktreeLineageById,
worktreeMap,
true,
settings
settings,
projectGroups
),
[
groupBy,
@ -2895,10 +3030,11 @@ const WorktreeList = React.memo(function WorktreeList({
collapsedGroups,
repoOrder,
workspaceStatuses,
repoGroupOrdering,
projectGroupOrdering,
worktreeLineageById,
worktreeMap,
settings
settings,
projectGroups
]
)
// Why: header/mode changes can shift entire groups, so remount the
@ -3020,28 +3156,28 @@ const WorktreeList = React.memo(function WorktreeList({
}, [renderedWorktreeIds])
const handleCreateForRepo = useCallback(
(repoId: string) => {
openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' })
(projectId: string) => {
openModal('new-workspace-composer', { initialRepoId: projectId, telemetrySource: 'sidebar' })
},
[openModal]
)
const handleOpenRepoSettings = useCallback(
(repoId: string, sectionId?: string) => {
openSettingsTarget({ pane: 'repo', repoId, ...(sectionId ? { sectionId } : {}) })
(projectId: string, sectionId?: string) => {
openSettingsTarget({ pane: 'repo', repoId: projectId, ...(sectionId ? { sectionId } : {}) })
openSettingsPage()
},
[openSettingsPage, openSettingsTarget]
)
const handleOpenWorktreeVisibility = useCallback(
(repoId: string) => {
openModal('worktree-visibility', { repoId })
(projectId: string) => {
openModal('worktree-visibility', { repoId: projectId })
},
[openModal]
)
const handleRemoveRepo = useCallback(
const handleRemoveProject = useCallback(
(repo: Repo) => {
openModal('confirm-remove-folder', {
repoId: repo.id,
@ -3051,6 +3187,68 @@ const WorktreeList = React.memo(function WorktreeList({
[openModal]
)
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)
const deleteProjectGroup = useAppStore((s) => s.deleteProjectGroup)
const [projectGroupNameDialog, setProjectGroupNameDialog] =
useState<ProjectGroupNameDialogState | null>(null)
const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] =
useState<ProjectGroupDeleteDialogState | null>(null)
const handleCreateGroupFromRepo = useCallback((repo: Repo) => {
setProjectGroupNameDialog({ type: 'create-from-repo', repo })
}, [])
const handleMoveProjectToGroup = useCallback(
(repo: Repo, groupId: string) => {
if (repo.projectGroupId === groupId) {
return
}
void moveProjectToGroup(repo.id, groupId)
},
[moveProjectToGroup]
)
const handleRemoveProjectFromGroup = useCallback(
(repo: Repo) => {
void moveProjectToGroup(repo.id, null)
},
[moveProjectToGroup]
)
const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => {
setProjectGroupNameDialog({ type: 'rename', groupId, currentName })
}, [])
const handleSubmitProjectGroupName = useCallback(
async (name: string) => {
if (!projectGroupNameDialog) {
return
}
if (projectGroupNameDialog.type === 'create-from-repo') {
const group = await createProjectGroup(name)
if (group) {
await moveProjectToGroup(projectGroupNameDialog.repo.id, group.id)
}
return
}
await updateProjectGroup(projectGroupNameDialog.groupId, { name })
},
[createProjectGroup, moveProjectToGroup, projectGroupNameDialog, updateProjectGroup]
)
const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => {
setProjectGroupDeleteDialog({ groupId, groupName })
}, [])
const handleConfirmDeleteProjectGroup = useCallback(async () => {
if (!projectGroupDeleteDialog) {
return
}
await deleteProjectGroup(projectGroupDeleteDialog.groupId)
}, [deleteProjectGroup, projectGroupDeleteDialog])
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
const current = worktreeMap.get(worktreeId)
@ -3218,46 +3416,89 @@ const WorktreeList = React.memo(function WorktreeList({
}
return (
<VirtualizedWorktreeViewport
key={viewportResetKey}
rows={rows}
activeWorktreeId={selectedSidebarWorktreeId}
currentWorktreeId={activeWorktreeId}
groupBy={groupBy}
repoGroupOrdering={repoGroupOrdering}
toggleGroup={toggleGroup}
collapsedGroups={collapsedGroups}
handleCreateForRepo={handleCreateForRepo}
handleOpenRepoSettings={handleOpenRepoSettings}
handleOpenWorktreeVisibility={handleOpenWorktreeVisibility}
handleRemoveRepo={handleRemoveRepo}
activeModal={activeModal}
pendingRevealWorktree={pendingRevealWorktree}
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
worktrees={worktrees}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={updateSelectionForGesture}
onContextMenuSelect={selectForContextMenu}
repoMap={repoMap}
worktreeMap={worktreeMap}
worktreeLineageById={worktreeLineageById}
repoOrder={repoOrder}
allRepoIds={allRepoIds}
reorderRepos={(orderedIds) => {
void reorderReposAction(orderedIds)
}}
prCache={prCache}
workspaceStatuses={workspaceStatuses}
onMoveWorktreeToStatus={moveWorktreeToStatus}
onMoveWorktreesToStatus={moveWorktreesToStatus}
onPinWorktree={pinWorktree}
onPinWorktrees={pinWorktrees}
onReorderWorktrees={reorderWorktrees}
showInlineAgentCards={cardProps.includes('inline-agents')}
scrollOffsetRef={scrollOffsetRef}
scrollAnchorRef={scrollAnchorRef}
/>
<>
<ProjectGroupNameDialog
open={projectGroupNameDialog !== null}
title={
projectGroupNameDialog?.type === 'rename' ? 'Rename Project Group' : 'New Project Group'
}
description={
projectGroupNameDialog?.type === 'rename'
? 'Update the group name shown in the sidebar.'
: 'Create a group and move this project into it.'
}
initialName={
projectGroupNameDialog?.type === 'rename'
? projectGroupNameDialog.currentName
: projectGroupNameDialog
? `${projectGroupNameDialog.repo.displayName} group`
: ''
}
confirmLabel={projectGroupNameDialog?.type === 'rename' ? 'Rename' : 'Create'}
onOpenChange={(open) => {
if (!open) {
setProjectGroupNameDialog(null)
}
}}
onSubmit={handleSubmitProjectGroupName}
/>
<ProjectGroupDeleteDialog
open={projectGroupDeleteDialog !== null}
groupName={projectGroupDeleteDialog?.groupName ?? ''}
onOpenChange={(open) => {
if (!open) {
setProjectGroupDeleteDialog(null)
}
}}
onConfirm={handleConfirmDeleteProjectGroup}
/>
<VirtualizedWorktreeViewport
key={viewportResetKey}
rows={rows}
activeWorktreeId={selectedSidebarWorktreeId}
currentWorktreeId={activeWorktreeId}
groupBy={groupBy}
projectGroupOrdering={projectGroupOrdering}
toggleGroup={toggleGroup}
collapsedGroups={collapsedGroups}
handleCreateForRepo={handleCreateForRepo}
handleOpenRepoSettings={handleOpenRepoSettings}
handleOpenWorktreeVisibility={handleOpenWorktreeVisibility}
handleRemoveProject={handleRemoveProject}
handleCreateGroupFromRepo={handleCreateGroupFromRepo}
handleMoveProjectToGroup={handleMoveProjectToGroup}
handleRemoveProjectFromGroup={handleRemoveProjectFromGroup}
handleRenameProjectGroup={handleRenameProjectGroup}
handleDeleteProjectGroup={handleDeleteProjectGroup}
activeModal={activeModal}
pendingRevealWorktree={pendingRevealWorktree}
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
worktrees={worktrees}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={updateSelectionForGesture}
onContextMenuSelect={selectForContextMenu}
repoMap={repoMap}
worktreeMap={worktreeMap}
worktreeLineageById={worktreeLineageById}
repoOrder={repoOrder}
allRepoIds={allRepoIds}
reorderRepos={(orderedIds) => {
void reorderReposAction(orderedIds)
}}
prCache={prCache}
workspaceStatuses={workspaceStatuses}
projectGroups={projectGroups}
onMoveWorktreeToStatus={moveWorktreeToStatus}
onMoveWorktreesToStatus={moveWorktreesToStatus}
onPinWorktree={pinWorktree}
onPinWorktrees={pinWorktrees}
onReorderWorktrees={reorderWorktrees}
showInlineAgentCards={cardProps.includes('inline-agents')}
scrollOffsetRef={scrollOffsetRef}
scrollAnchorRef={scrollAnchorRef}
/>
</>
)
})

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants'
import { resolveRepoGroupHeaderColor, resolveRepoHeaderColor } from './repo-header-color'
import { resolveProjectGroupHeaderColor, resolveRepoHeaderColor } from './project-header-color'
describe('resolveRepoHeaderColor', () => {
it('returns a canonical palette color', () => {
@ -24,10 +24,10 @@ describe('resolveRepoHeaderColor', () => {
})
})
describe('resolveRepoGroupHeaderColor', () => {
it('returns the repo color for repo group headers', () => {
describe('resolveProjectGroupHeaderColor', () => {
it('returns the repo color for project group headers', () => {
expect(
resolveRepoGroupHeaderColor({
resolveProjectGroupHeaderColor({
groupBy: 'repo',
headerKey: 'repo:repo-1',
badgeColor: REPO_COLORS[5]
@ -35,9 +35,9 @@ describe('resolveRepoGroupHeaderColor', () => {
).toBe(REPO_COLORS[5])
})
it('falls back to gray for unknown repo group headers', () => {
it('falls back to gray for unknown project group headers', () => {
expect(
resolveRepoGroupHeaderColor({
resolveProjectGroupHeaderColor({
groupBy: 'repo',
headerKey: 'repo:missing-repo',
badgeColor: undefined
@ -47,7 +47,7 @@ describe('resolveRepoGroupHeaderColor', () => {
it('does not color pinned headers while grouped by repo', () => {
expect(
resolveRepoGroupHeaderColor({
resolveProjectGroupHeaderColor({
groupBy: 'repo',
headerKey: 'pinned',
badgeColor: undefined
@ -57,7 +57,7 @@ describe('resolveRepoGroupHeaderColor', () => {
it('does not color repo-looking keys in other grouping modes', () => {
expect(
resolveRepoGroupHeaderColor({
resolveProjectGroupHeaderColor({
groupBy: 'workspace-status',
headerKey: 'repo:repo-1',
badgeColor: REPO_COLORS[2]

View File

@ -1,6 +1,6 @@
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants'
const REPO_GROUP_HEADER_KEY_PREFIX = 'repo:'
const PROJECT_GROUP_HEADER_KEY_PREFIX = 'repo:'
export function resolveRepoHeaderColor(badgeColor: string | null | undefined): string {
const normalizedBadgeColor = badgeColor?.trim().toLowerCase()
@ -15,14 +15,14 @@ export function resolveRepoHeaderColor(badgeColor: string | null | undefined): s
)
}
export function resolveRepoGroupHeaderColor(args: {
export function resolveProjectGroupHeaderColor(args: {
groupBy: string
headerKey: string
badgeColor: string | null | undefined
}): string | undefined {
// Why: pinned headers can appear while grouped by repo, but only repo:* headers
// represent a repo folder whose user-authored badge color should be shown.
if (args.groupBy !== 'repo' || !args.headerKey.startsWith(REPO_GROUP_HEADER_KEY_PREFIX)) {
if (args.groupBy !== 'repo' || !args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX)) {
return undefined
}
return resolveRepoHeaderColor(args.badgeColor)

View File

@ -113,7 +113,7 @@ export function useRepoHeaderDrag({
}
}
// Why anchor to the target header (not midpoint between headers): the
// space between two repo group headers is filled with worktree cards,
// space between two project group headers is filled with worktree cards,
// so the midpoint falls *inside another repo's content*. Sitting the
// indicator just above the target header keeps it at the visual top of
// where the dragged group would land.

View File

@ -6,12 +6,13 @@ import {
ALL_GROUP_META,
buildRows,
getGroupKeyForWorktree,
getGroupKeysForWorktree,
getLineageGroupKey,
getLineageRenderInfo,
getPRGroupKey,
getRepoGroupOrdering
getProjectGroupOrdering
} from './worktree-list-groups'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
import type { Repo, ProjectGroup, Worktree, WorktreeLineage } from '../../../../shared/types'
const repo: Repo = {
id: 'repo-1',
@ -308,7 +309,7 @@ describe('buildRows with pinned worktrees', () => {
})
})
describe('buildRows repo grouping order', () => {
describe('buildRows project grouping order', () => {
const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha' }
const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta' }
const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma' }
@ -394,7 +395,7 @@ describe('buildRows repo grouping order', () => {
])
})
it('keeps repoOrder for manual repo group ordering', () => {
it('keeps repoOrder for manual project group ordering', () => {
const repoOrder = new Map([
[repoB.id, 0],
[repoA.id, 1],
@ -406,7 +407,7 @@ describe('buildRows repo grouping order', () => {
})
})
describe('getRepoGroupOrdering', () => {
describe('getProjectGroupOrdering', () => {
it.each([
['repo', 'recent', 'visible-worktree-order'],
['repo', 'smart', 'visible-worktree-order'],
@ -416,7 +417,187 @@ describe('getRepoGroupOrdering', () => {
['workspace-status', 'recent', 'manual'],
['pr-status', 'recent', 'manual']
] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {
expect(getRepoGroupOrdering(groupBy, sortBy)).toBe(expected)
expect(getProjectGroupOrdering(groupBy, sortBy)).toBe(expected)
})
})
describe('project groups', () => {
it('keeps empty project groups visible in project grouping mode', () => {
const group: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const rows = buildRows(
'repo',
[],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[group]
)
expect(rows).toEqual([
expect.objectContaining({
type: 'header',
key: 'project-group:group-1',
label: 'Platform',
count: 0,
projectGroup: group
})
])
})
it('orders repos inside a Project Group by projectGroupOrder in manual mode', () => {
const group: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const repoA: Repo = {
...repo,
id: 'repo-a',
displayName: 'alpha',
projectGroupId: group.id,
projectGroupOrder: 1
}
const repoB: Repo = {
...repo,
id: 'repo-b',
displayName: 'beta',
projectGroupId: group.id,
projectGroupOrder: 0
}
const worktreeA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id }
const worktreeB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id }
const groupedMap = new Map([
[repoA.id, repoA],
[repoB.id, repoB]
])
const repoOrder = new Map([
[repoA.id, 0],
[repoB.id, 1]
])
const rows = buildRows(
'repo',
[worktreeA, worktreeB],
groupedMap,
null,
new Set(),
repoOrder,
undefined,
'manual',
undefined,
undefined,
false,
undefined,
[group]
)
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
'project-group:group-1',
'repo:repo-b',
'repo:repo-a'
])
})
it('renders nested Project Groups before repos assigned to their leaf group', () => {
const rootGroup: ProjectGroup = {
id: 'group-root',
name: 'Services',
parentPath: '/monorepo',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const childGroup: ProjectGroup = {
...rootGroup,
id: 'group-payments',
name: 'payments',
parentPath: '/monorepo/services/payments',
parentGroupId: rootGroup.id,
tabOrder: 1
}
const groupedRepo: Repo = {
...repo,
id: 'repo-payments-api',
displayName: 'api',
projectGroupId: childGroup.id,
projectGroupOrder: 0
}
const groupedWorktree: Worktree = {
...worktree,
id: 'wt-payments-api',
repoId: groupedRepo.id
}
const rows = buildRows(
'repo',
[groupedWorktree],
new Map([[groupedRepo.id, groupedRepo]]),
null,
new Set(),
new Map([[groupedRepo.id, 0]]),
undefined,
'manual',
undefined,
undefined,
false,
undefined,
[rootGroup, childGroup]
)
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
'project-group:group-root',
'project-group:group-payments',
'repo:repo-payments-api'
])
expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual(
[0, 1, 2]
)
expect(rows[0]).toMatchObject({ count: 1 })
})
it('returns both parent Project Group and repo keys for grouped repo reveals', () => {
const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' }
expect(
getGroupKeysForWorktree('repo', worktree, new Map([[groupedRepo.id, groupedRepo]]), null)
).toEqual(['project-group:group-1', 'repo:repo-1'])
})
it('returns the Ungrouped parent key for ungrouped repo reveals', () => {
expect(getGroupKeysForWorktree('repo', worktree, repoMap, null)).toEqual([
'project-group:ungrouped',
'repo:repo-1'
])
})
})
@ -669,13 +850,13 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('[&_path]:cursor-pointer')
})
it('resolves repo header color from repo group headers only', () => {
it('resolves repo header color from project group headers only', () => {
const source = readFileSync(
fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)),
'utf8'
)
expect(source).toContain('resolveRepoGroupHeaderColor({')
expect(source).toContain('resolveProjectGroupHeaderColor({')
expect(source).toContain('headerKey: row.key')
expect(source).toContain('color={repoHeaderColor}')
})

View File

@ -1,8 +1,9 @@
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
import { CircleX, Folder, List, Pin } from 'lucide-react'
import { CircleX, FolderTree, List, Pin } from 'lucide-react'
import type React from 'react'
import type {
Repo,
ProjectGroup,
Worktree,
WorktreeLineage,
WorkspaceStatusDefinition
@ -23,13 +24,17 @@ import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-stat
import type { SortBy } from './smart-sort'
import type { AppState } from '@/store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
export { branchName }
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
export type RepoGroupOrdering = 'manual' | 'visible-worktree-order'
export type ProjectGroupOrdering = 'manual' | 'visible-worktree-order'
export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering {
export function getProjectGroupOrdering(
groupBy: WorktreeGroupBy,
sortBy: SortBy
): ProjectGroupOrdering {
return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart')
? 'visible-worktree-order'
: 'manual'
@ -43,6 +48,8 @@ export type GroupHeaderRow = {
tone: string
icon?: React.ComponentType<{ className?: string }>
repo?: Repo
projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number }
projectGroupDepth?: number
}
export type WorktreeRow = {
@ -92,11 +99,15 @@ export const PR_GROUP_META: Record<
}
}
export const REPO_GROUP_META = {
export const PROJECT_GROUP_META = {
tone: 'text-foreground',
icon: Folder
icon: FolderTree
} as const
export function getProjectGroupHeaderKey(groupId: string | null): string {
return groupId ? `project-group:${groupId}` : UNGROUPED_PROJECT_GROUP_KEY
}
export const PINNED_GROUP_KEY = 'pinned'
export const PINNED_GROUP_META = {
@ -345,13 +356,14 @@ export function buildRows(
collapsedGroups: Set<string>,
repoOrder?: Map<string, number>,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
repoGroupOrdering: RepoGroupOrdering = 'manual',
projectGroupOrdering: ProjectGroupOrdering = 'manual',
lineageById: Record<string, WorktreeLineage> = {},
worktreeMap: Map<string, Worktree> = new Map(
worktrees.map((worktree) => [worktree.id, worktree])
),
nestLineage = false,
settings?: AppState['settings']
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = []
): Row[] {
const result: Row[] = []
@ -434,7 +446,7 @@ export function buildRows(
// visible child. Manual ordering still uses the canonical state.repos
// order so repo-header drag has a stable source of truth.
const entries = Array.from(grouped.entries())
if (repoGroupOrdering === 'manual' && repoOrder) {
if (projectGroupOrdering === 'manual' && repoOrder) {
const rankFor = (key: string): number => {
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
const rank = repoOrder.get(repoId)
@ -452,56 +464,168 @@ export function buildRows(
orderedGroups.push(...entries)
}
for (const [key, group] of orderedGroups) {
const isCollapsed = collapsedGroups.has(key)
const repo = group.repo
const header =
groupBy === 'repo'
? {
type: 'header' as const,
key,
label: group.label,
count: group.items.length,
tone: REPO_GROUP_META.tone,
icon: REPO_GROUP_META.icon,
repo
}
: groupBy === 'workspace-status'
? (() => {
const workspaceStatus =
getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
workspaceStatuses[0]?.id ??
'in-progress'
const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
return {
type: 'header' as const,
key,
label: definition?.label ?? workspaceStatus,
count: group.items.length,
tone: meta.tone,
icon: meta.icon
}
})()
: (() => {
const prGroup = key.replace(/^pr:/, '') as PRGroupKey
const meta = PR_GROUP_META[prGroup]
return {
type: 'header' as const,
key,
label: meta.label,
count: group.items.length,
tone: meta.tone,
icon: meta.icon
}
})()
const appendOrderedGroups = (
groupsToAppend: [string, { label: string; items: Worktree[]; repo?: Repo }][],
projectGroupDepth = 0
): void => {
for (const [key, group] of groupsToAppend) {
const isCollapsed = collapsedGroups.has(key)
const repo = group.repo
const header =
groupBy === 'repo'
? {
type: 'header' as const,
key,
label: group.label,
count: group.items.length,
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
repo,
projectGroupDepth
}
: groupBy === 'workspace-status'
? (() => {
const workspaceStatus =
getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
workspaceStatuses[0]?.id ??
'in-progress'
const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
return {
type: 'header' as const,
key,
label: definition?.label ?? workspaceStatus,
count: group.items.length,
tone: meta.tone,
icon: meta.icon
}
})()
: (() => {
const prGroup = key.replace(/^pr:/, '') as PRGroupKey
const meta = PR_GROUP_META[prGroup]
return {
type: 'header' as const,
key,
label: meta.label,
count: group.items.length,
tone: meta.tone,
icon: meta.icon
}
})()
result.push(header)
if (!isCollapsed) {
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups
})
result.push(header)
if (!isCollapsed) {
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups
})
}
}
}
if (groupBy !== 'repo' || projectGroups.length === 0) {
appendOrderedGroups(orderedGroups)
return result
}
const groupByProjectGroupId = new Map<
string | null,
[string, { label: string; items: Worktree[]; repo?: Repo }][]
>()
for (const entry of orderedGroups) {
const repo = entry[1].repo
const projectGroupId = repo?.projectGroupId ?? null
const list = groupByProjectGroupId.get(projectGroupId) ?? []
list.push(entry)
groupByProjectGroupId.set(projectGroupId, list)
}
const sortRepoEntriesWithinGroup = (
entries: [string, { label: string; items: Worktree[]; repo?: Repo }][]
): [string, { label: string; items: Worktree[]; repo?: Repo }][] => {
if (projectGroupOrdering !== 'manual') {
return entries
}
return [...entries].sort((left, right) => {
const leftOrder = left[1].repo?.projectGroupOrder
const rightOrder = right[1].repo?.projectGroupOrder
const leftRank =
typeof leftOrder === 'number' && Number.isFinite(leftOrder)
? leftOrder
: Number.POSITIVE_INFINITY
const rightRank =
typeof rightOrder === 'number' && Number.isFinite(rightOrder)
? rightOrder
: Number.POSITIVE_INFINITY
return leftRank - rightRank
})
}
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
for (const group of projectGroups) {
const parentId =
group.parentGroupId && projectGroupsById.has(group.parentGroupId) ? group.parentGroupId : null
const children = childGroupsByParentId.get(parentId) ?? []
children.push(group)
childGroupsByParentId.set(parentId, children)
}
for (const groups of childGroupsByParentId.values()) {
groups.sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
}
const getProjectGroupSubtreeCount = (groupId: string): number => {
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
const children = childGroupsByParentId.get(groupId) ?? []
return children.reduce(
(count, child) => count + getProjectGroupSubtreeCount(child.id),
directCount
)
}
const appendProjectGroup = (projectGroup: ProjectGroup, depth: number): void => {
const repoEntries = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(projectGroup.id) ?? [])
const childGroups = childGroupsByParentId.get(projectGroup.id) ?? []
const key = getProjectGroupHeaderKey(projectGroup.id)
result.push({
type: 'header',
key,
label: projectGroup.name,
count: getProjectGroupSubtreeCount(projectGroup.id),
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
projectGroup,
projectGroupDepth: depth
})
if (!collapsedGroups.has(key)) {
appendOrderedGroups(repoEntries, depth + 1)
for (const childGroup of childGroups) {
appendProjectGroup(childGroup, depth + 1)
}
}
groupByProjectGroupId.delete(projectGroup.id)
}
for (const projectGroup of childGroupsByParentId.get(null) ?? []) {
appendProjectGroup(projectGroup, 0)
}
const ungrouped = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(null) ?? [])
if (ungrouped.length > 0) {
const key = getProjectGroupHeaderKey(null)
result.push({
type: 'header',
key,
label: 'Ungrouped',
count: ungrouped.length,
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
projectGroup: { id: null, name: 'Ungrouped', tabOrder: Number.MAX_SAFE_INTEGER }
})
if (!collapsedGroups.has(key)) {
appendOrderedGroups(ungrouped, 1)
}
}
@ -527,3 +651,45 @@ export function getGroupKeyForWorktree(
}
return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}`
}
export function getGroupKeysForWorktree(
groupBy: WorktreeGroupBy,
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = []
): string[] {
const groupKey = getGroupKeyForWorktree(
groupBy,
worktree,
repoMap,
prCache,
workspaceStatuses,
settings
)
if (!groupKey) {
return []
}
if (groupBy !== 'repo') {
return [groupKey]
}
const repo = repoMap.get(worktree.repoId)
const groupIds: string[] = []
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const visited = new Set<string>()
let currentGroupId = repo?.projectGroupId ?? null
while (currentGroupId && !visited.has(currentGroupId)) {
visited.add(currentGroupId)
groupIds.unshift(currentGroupId)
const parentId = groupsById.get(currentGroupId)?.parentGroupId ?? null
currentGroupId = parentId && groupsById.has(parentId) ? parentId : null
}
return [
...(groupIds.length > 0
? groupIds.map((id) => getProjectGroupHeaderKey(id))
: [getProjectGroupHeaderKey(null)]),
groupKey
]
}

View File

@ -44,7 +44,7 @@ import {
UNATTRIBUTED_REPO_ID,
type DaemonSession,
type Metric,
type UnifiedRepoGroup,
type UnifiedProjectGroup,
type UnifiedSessionRow,
type UnifiedWorktreeRow
} from './mergeSnapshotAndSessions'
@ -298,7 +298,7 @@ function sortWorktrees(list: UnifiedWorktreeRow[], sort: SortOption): UnifiedWor
return copy
}
function sortRepoGroups(groups: UnifiedRepoGroup[], sort: SortOption): UnifiedRepoGroup[] {
function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): UnifiedProjectGroup[] {
const copy = [...groups]
if (sort === 'memory') {
copy.sort((a, b) => compareMetricDesc(a.memory, b.memory))
@ -543,7 +543,7 @@ function ResourceTree({
onDelete,
onKillSession
}: {
repos: UnifiedRepoGroup[]
repos: UnifiedProjectGroup[]
sortOption: SortOption
collapsedRepos: Set<string>
toggleRepo: (repoId: string) => void
@ -558,7 +558,7 @@ function ResourceTree({
const worktreeById = useWorktreeMap()
const sortedRepos = useMemo(() => {
const grouped = sortRepoGroups(repos, sortOption)
const grouped = sortProjectGroups(repos, sortOption)
return grouped.map((repo) => ({
...repo,
worktrees: sortWorktrees(repo.worktrees, sortOption)

View File

@ -303,7 +303,7 @@ describe('mergeSnapshotAndSessions', () => {
})
})
it('uses repoDisplayNameById to humanize new repo groups when available', () => {
it('uses repoDisplayNameById to humanize new project groups when available', () => {
const ds: DaemonSession[] = [{ id: 'stably-ai/orca::/remote/Wt@@1', cwd: '', title: '' }]
const ctx = baseCtx({
repoDisplayNameById: new Map([['stably-ai/orca', 'ORCA']])

View File

@ -74,7 +74,7 @@ export type UnifiedWorktreeRow = {
sessions: UnifiedSessionRow[]
}
export type UnifiedRepoGroup = {
export type UnifiedProjectGroup = {
repoId: string
repoName: string
cpu: Metric
@ -236,8 +236,8 @@ export function mergeSnapshotAndSessions(
snapshot: MemorySnapshot | null,
daemonSessions: readonly DaemonSession[],
ctx: MergeContext
): UnifiedRepoGroup[] {
const repos = new Map<string, UnifiedRepoGroup>()
): UnifiedProjectGroup[] {
const repos = new Map<string, UnifiedProjectGroup>()
const seenSessionIds = new Set<string>()
const index = buildMergeIndex(ctx)
// Why: bound = the daemon session id appears as a pty id under some tab.
@ -260,12 +260,12 @@ export function mergeSnapshotAndSessions(
repoId: string,
repoName: string,
initiallyHasRemoteChildren = false
): UnifiedRepoGroup {
): UnifiedProjectGroup {
const existing = repos.get(repoId)
if (existing) {
return existing
}
const next: UnifiedRepoGroup = {
const next: UnifiedProjectGroup = {
repoId,
repoName,
cpu: null,
@ -278,7 +278,7 @@ export function mergeSnapshotAndSessions(
}
function findWorktreeRow(
repo: UnifiedRepoGroup,
repo: UnifiedProjectGroup,
worktreeId: string
): UnifiedWorktreeRow | undefined {
return repo.worktrees.find((w) => w.worktreeId === worktreeId)

View File

@ -592,7 +592,9 @@ export function useIpcEvents(): void {
// selected server instead of local-disk changes.
return
}
useAppStore.getState().fetchRepos()
const state = useAppStore.getState()
void state.fetchProjectGroups()
void state.fetchRepos()
})
)

View File

@ -1724,7 +1724,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => {
const state = get()
let failedCount = 0
const perRepoResults = await Promise.all(
const perProjectResults = await Promise.all(
repos.map(async (r) => {
try {
return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, options)
@ -1750,13 +1750,13 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})
)
const merged = sortWorkItemsByUpdatedAt(perRepoResults.flat()).slice(0, displayLimit)
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
},
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => {
let failedCount = 0
const perRepoResults = await Promise.all(
const perProjectResults = await Promise.all(
repos.map(async (r) => {
await acquireWorkItemSlot()
try {
@ -1793,7 +1793,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})
)
const merged = sortWorkItemsByUpdatedAt(perRepoResults.flat()).slice(0, displayLimit)
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
},

View File

@ -0,0 +1,204 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { Repo, ProjectGroup } from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
const remoteRepo: Repo = {
id: 'remote-repo',
path: '/remote',
displayName: 'Remote',
badgeColor: '#111',
addedAt: 2
}
const projectGroup: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const reposList = vi.fn()
const projectGroupsList = vi.fn()
const projectGroupsCreate = vi.fn()
const projectGroupsDelete = vi.fn()
const projectGroupsMoveProject = vi.fn()
const projectGroupsImportNested = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
projectGroupsList.mockReset()
projectGroupsCreate.mockReset()
projectGroupsDelete.mockReset()
projectGroupsMoveProject.mockReset()
projectGroupsImportNested.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
repos: {
list: reposList
},
projectGroups: {
list: projectGroupsList,
create: projectGroupsCreate,
delete: projectGroupsDelete,
moveProject: projectGroupsMoveProject,
importNested: projectGroupsImportNested
},
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
})
describe('project group store routing', () => {
it('creates local project groups without contacting the runtime transport', async () => {
projectGroupsCreate.mockResolvedValue(projectGroup)
const store = createTestStore()
await expect(store.getState().createProjectGroup('Platform')).resolves.toEqual(projectGroup)
expect(store.getState().projectGroups).toEqual([projectGroup])
expect(projectGroupsCreate).toHaveBeenCalledWith({
name: 'Platform',
createdFrom: 'manual'
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('refreshes local repos and groups after importing nested repos', async () => {
const importedRepo: Repo = {
...remoteRepo,
id: 'local-imported',
path: '/platform/api',
projectGroupId: projectGroup.id,
projectGroupOrder: 0
}
const result = {
group: projectGroup,
repos: [{ path: importedRepo.path, projectId: importedRepo.id, status: 'imported' as const }],
importedCount: 1,
alreadyKnownCount: 0,
failedCount: 0
}
projectGroupsImportNested.mockResolvedValue(result)
projectGroupsList.mockResolvedValue([projectGroup])
reposList.mockResolvedValue([importedRepo])
const store = createTestStore()
await expect(
store.getState().importNestedRepos({
parentPath: '/platform',
groupName: 'Platform',
projectPaths: [importedRepo.path],
mode: 'group'
})
).resolves.toEqual(result)
expect(projectGroupsImportNested).toHaveBeenCalledWith({
parentPath: '/platform',
groupName: 'Platform',
projectPaths: [importedRepo.path],
mode: 'group'
})
expect(projectGroupsList).toHaveBeenCalled()
expect(reposList).toHaveBeenCalled()
expect(store.getState().projectGroups).toEqual([projectGroup])
expect(store.getState().repos).toEqual([importedRepo])
})
it('moves local repos to a group using the preload projectId contract', async () => {
const movedRepo = { ...remoteRepo, projectGroupId: projectGroup.id, projectGroupOrder: 3 }
projectGroupsMoveProject.mockResolvedValue(movedRepo)
const store = createTestStore()
store.setState({ repos: [remoteRepo], projectGroups: [projectGroup] })
await expect(
store.getState().moveProjectToGroup(remoteRepo.id, projectGroup.id, 3)
).resolves.toBe(true)
expect(projectGroupsMoveProject).toHaveBeenCalledWith({
projectId: remoteRepo.id,
groupId: projectGroup.id,
order: 3
})
expect(store.getState().repos).toEqual([movedRepo])
})
it('removes local project group subtrees from renderer state after delete', async () => {
const childGroup: ProjectGroup = {
...projectGroup,
id: 'child',
parentGroupId: projectGroup.id
}
const siblingGroup: ProjectGroup = {
...projectGroup,
id: 'sibling',
name: 'Tools',
tabOrder: 1
}
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
projectGroups: [projectGroup, childGroup, siblingGroup],
repos: [
{ ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id },
{ ...remoteRepo, id: 'nested', projectGroupId: childGroup.id },
{ ...remoteRepo, id: 'sibling', projectGroupId: siblingGroup.id }
]
})
await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(true)
expect(store.getState().projectGroups.map((group) => group.id)).toEqual([siblingGroup.id])
expect(store.getState().repos).toMatchObject([
{ id: 'direct', projectGroupId: null },
{ id: 'nested', projectGroupId: null },
{ id: 'sibling', projectGroupId: siblingGroup.id }
])
})
it('uses the remote delete response shape before mutating local state', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-delete-group',
ok: true,
result: { deleted: false },
_meta: { runtimeId: 'runtime-remote' }
})
const groupedRepo = { ...remoteRepo, projectGroupId: projectGroup.id }
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
projectGroups: [projectGroup],
repos: [groupedRepo]
})
await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false)
expect(store.getState().projectGroups).toEqual([projectGroup])
expect(store.getState().repos).toEqual([groupedRepo])
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'projectGroup.delete',
params: { groupId: projectGroup.id },
timeoutMs: 15_000
})
expect(projectGroupsDelete).not.toHaveBeenCalled()
})
})

View File

@ -182,7 +182,7 @@ describe('repo slice runtime routing', () => {
activeRepoId: remoteRepo.id
})
await store.getState().removeRepo(remoteRepo.id)
await store.getState().removeProject(remoteRepo.id)
expect(store.getState().repos).toEqual([])
expect(store.getState().activeRepoId).toBeNull()
@ -212,7 +212,7 @@ describe('repo slice runtime routing', () => {
}
})
await store.getState().removeRepo(localRepo.id)
await store.getState().removeProject(localRepo.id)
expect(Object.keys(store.getState().workItemsCache)).toEqual([
workItemsCacheKey('other-repo', 20, '')
@ -244,7 +244,7 @@ describe('repo slice runtime routing', () => {
}
})
await store.getState().removeRepo(remoteRepo.id)
await store.getState().removeProject(remoteRepo.id)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
@ -290,7 +290,7 @@ describe('repo slice runtime routing', () => {
activeWorktreeId: hiddenWorktree.id
})
await store.getState().removeRepo(localRepo.id)
await store.getState().removeProject(localRepo.id)
expect(store.getState().detectedWorktreesByRepo[localRepo.id]).toBeUndefined()
expect(store.getState().tabsByWorktree[hiddenWorktree.id]).toBeUndefined()

View File

@ -5,9 +5,15 @@ auditing and preserving. */
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { Repo } from '../../../../shared/types'
import type {
Repo,
ProjectGroup,
ProjectGroupImportResult,
NestedRepoScanResult
} from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
@ -27,6 +33,8 @@ type RepoUpdate = Partial<
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'projectGroupId'
| 'projectGroupOrder'
| 'sourceControlAi'
>
>
@ -55,12 +63,12 @@ function getRepoUpdateChains(get: () => AppState): Map<string, Promise<boolean>>
return chains
}
function getKnownRepoWorktreeIds(state: AppState, repoId: string): string[] {
function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] {
const ids = new Set<string>()
for (const worktree of state.worktreesByRepo[repoId] ?? []) {
for (const worktree of state.worktreesByRepo[projectId] ?? []) {
ids.add(worktree.id)
}
for (const worktree of state.detectedWorktreesByRepo[repoId]?.worktrees ?? []) {
for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) {
ids.add(worktree.id)
}
return [...ids]
@ -68,19 +76,41 @@ function getKnownRepoWorktreeIds(state: AppState, repoId: string): string[] {
export type RepoSlice = {
repos: Repo[]
projectGroups: ProjectGroup[]
activeRepoId: string | null
fetchRepos: () => Promise<void>
fetchProjectGroups: () => Promise<void>
addRepo: () => Promise<Repo | null>
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
addNonGitFolder: (path: string) => Promise<Repo | null>
removeRepo: (repoId: string) => Promise<void>
updateRepo: (repoId: string, updates: RepoUpdate) => Promise<boolean>
setActiveRepo: (repoId: string | null) => void
scanNestedRepos: (path: string, connectionId?: string) => Promise<NestedRepoScanResult | null>
importNestedRepos: (args: {
parentPath: string
groupName: string
projectPaths: string[]
connectionId?: string
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
updateProjectGroup: (
groupId: string,
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
) => Promise<boolean>
deleteProjectGroup: (groupId: string) => Promise<boolean>
moveProjectToGroup: (
projectId: string,
groupId: string | null,
order?: number
) => Promise<boolean>
removeProject: (projectId: string) => Promise<void>
updateRepo: (projectId: string, updates: RepoUpdate) => Promise<boolean>
setActiveRepo: (projectId: string | null) => void
reorderRepos: (orderedIds: string[]) => Promise<void>
}
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
repos: [],
projectGroups: [],
activeRepoId: null,
fetchRepos: async () => {
@ -105,9 +135,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
return {
repos,
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
filterRepoIds: s.filterRepoIds.filter((repoId) => validRepoIds.has(repoId)),
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds.filter((repoId) =>
validRepoIds.has(repoId)
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds.filter(
(projectId) => validRepoIds.has(projectId)
)
}
})
@ -116,6 +146,193 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
fetchProjectGroups: async () => {
try {
const target = getActiveRuntimeTarget(get().settings)
const projectGroups =
target.kind === 'local'
? ((await window.api.projectGroups.list()) as ProjectGroup[])
: (
await callRuntimeRpc<{ groups: ProjectGroup[] }>(
target,
'projectGroup.list',
undefined,
{
timeoutMs: 15_000
}
)
).groups
set({ projectGroups })
} catch (err) {
console.error('Failed to fetch project groups:', err)
}
},
scanNestedRepos: async (path, connectionId) => {
try {
const target = getActiveRuntimeTarget(get().settings)
return target.kind === 'local'
? ((await window.api.projectGroups.scanNested({
path,
connectionId
})) as NestedRepoScanResult)
: await callRuntimeRpc<NestedRepoScanResult>(
target,
'projectGroup.scanNested',
{ path },
{ timeoutMs: 15_000 }
)
} catch (err) {
console.error('Failed to scan nested repos:', err)
return null
}
},
importNestedRepos: async (args) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const result =
target.kind === 'local'
? ((await window.api.projectGroups.importNested(args)) as ProjectGroupImportResult)
: await callRuntimeRpc<ProjectGroupImportResult>(
target,
'projectGroup.importNested',
{
parentPath: args.parentPath,
groupName: args.groupName,
projectPaths: args.projectPaths,
mode: args.mode
},
{ timeoutMs: 60_000 }
)
await get().fetchProjectGroups()
await get().fetchRepos()
return result
} catch (err) {
console.error('Failed to import nested repos:', err)
toast.error('Failed to import repositories', {
description: err instanceof Error ? err.message : String(err)
})
return null
}
},
createProjectGroup: async (name) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const group =
target.kind === 'local'
? ((await window.api.projectGroups.create({
name,
createdFrom: 'manual'
})) as ProjectGroup)
: (
await callRuntimeRpc<{ group: ProjectGroup }>(
target,
'projectGroup.create',
{ name, createdFrom: 'manual' },
{ timeoutMs: 15_000 }
)
).group
set((s) => ({ projectGroups: [...s.projectGroups, group] }))
return group
} catch (err) {
console.error('Failed to create project group:', err)
return null
}
},
updateProjectGroup: async (groupId, updates) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const updated =
target.kind === 'local'
? ((await window.api.projectGroups.update({ groupId, updates })) as ProjectGroup | null)
: (
await callRuntimeRpc<{ group: ProjectGroup | null }>(
target,
'projectGroup.update',
{ groupId, updates },
{ timeoutMs: 15_000 }
)
).group
if (!updated) {
return false
}
set((s) => ({
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group))
}))
return true
} catch (err) {
console.error('Failed to update project group:', err)
return false
}
},
deleteProjectGroup: async (groupId) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const deleted =
target.kind === 'local'
? await window.api.projectGroups.delete({ groupId })
: (
await callRuntimeRpc<{ deleted: boolean }>(
target,
'projectGroup.delete',
{ groupId },
{ timeoutMs: 15_000 }
)
).deleted
if (!deleted) {
return false
}
set((s) => {
const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId)
return {
projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)),
repos: s.repos.map((repo) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
}
})
return true
} catch (err) {
console.error('Failed to delete project group:', err)
return false
}
},
moveProjectToGroup: async (projectId, groupId, order) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const moved =
target.kind === 'local'
? ((await window.api.projectGroups.moveProject({
projectId,
groupId,
order
})) as Repo | null)
: (
await callRuntimeRpc<{ repo: Repo | null }>(
target,
'projectGroup.moveProject',
{ repo: projectId, groupId, order },
{ timeoutMs: 15_000 }
)
).repo
if (!moved) {
return false
}
set((s) => ({ repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)) }))
return true
} catch (err) {
console.error('Failed to move repo to group:', err)
return false
}
},
addRepoPath: async (path, kind = 'git') => {
try {
const target = getActiveRuntimeTarget(get().settings)
@ -235,21 +452,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
removeRepo: async (repoId) => {
removeProject: async (projectId) => {
try {
const target = getActiveRuntimeTarget(get().settings)
await (target.kind === 'local'
? window.api.repos.remove({ repoId })
: callRuntimeRpc(target, 'repo.rm', { repo: repoId }, { timeoutMs: 15_000 }))
? window.api.repos.remove({ repoId: projectId })
: callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 }))
get().clearOrcaHookTrustForRepo(repoId)
const repoPath = get().repos.find((repo) => repo.id === repoId)?.path
get().evictGitHubRepoCaches(repoId, repoPath)
get().clearOrcaHookTrustForRepo(projectId)
const repoPath = get().repos.find((repo) => repo.id === projectId)?.path
get().evictGitHubRepoCaches(projectId, repoPath)
const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index')
clearRepoSlugCacheEntry(repoId)
clearRepoSlugCacheEntry(projectId)
// Kill PTYs for all worktrees belonging to this repo
const worktreeIds = getKnownRepoWorktreeIds(get(), repoId)
const worktreeIds = getKnownRepoWorktreeIds(get(), projectId)
const killedTabIds = new Set<string>()
const killedPtyIds = new Set<string>()
if (target.kind === 'environment') {
@ -274,9 +491,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
set((s) => {
const nextWorktrees = { ...s.worktreesByRepo }
delete nextWorktrees[repoId]
delete nextWorktrees[projectId]
const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo }
delete nextDetectedWorktrees[repoId]
delete nextDetectedWorktrees[projectId]
const nextTabs = { ...s.tabsByWorktree }
const nextLayouts = { ...s.terminalLayoutsByTabId }
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
@ -314,18 +531,18 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// forever after the repo is removed.
let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId
for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) {
if (getRepoIdFromWorktreeId(id) === repoId) {
if (getRepoIdFromWorktreeId(id) === projectId) {
if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) {
nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId }
}
delete nextLastVisitedAtByWorktreeId[id]
}
}
const nextRepos = s.repos.filter((r) => r.id !== repoId)
const nextRepos = s.repos.filter((r) => r.id !== projectId)
return {
repos: nextRepos,
activeRepoId: s.activeRepoId === repoId ? null : s.activeRepoId,
filterRepoIds: s.filterRepoIds.filter((id) => id !== repoId),
activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId,
filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId),
worktreesByRepo: nextWorktrees,
detectedWorktreesByRepo: nextDetectedWorktrees,
tabsByWorktree: nextTabs,
@ -359,22 +576,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
updateRepo: async (repoId, updates) => {
updateRepo: async (projectId, updates) => {
const updateRepoChains = getRepoUpdateChains(get)
const applyRepoUpdate = async () => {
try {
const sanitizedUpdates = sanitizeRepoUpdate(updates)
const target = getActiveRuntimeTarget(get().settings)
await (target.kind === 'local'
? window.api.repos.update({ repoId, updates: sanitizedUpdates })
? window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates })
: callRuntimeRpc(
target,
'repo.update',
{ repo: repoId, updates: sanitizedUpdates },
{ repo: projectId, updates: sanitizedUpdates },
{ timeoutMs: 15_000 }
))
set((s) => ({
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...sanitizedUpdates } : r))
repos: s.repos.map((r) => (r.id === projectId ? { ...r, ...sanitizedUpdates } : r))
}))
return true
} catch (err) {
@ -382,23 +599,23 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
return false
}
}
const previous = updateRepoChains.get(repoId)
const previous = updateRepoChains.get(projectId)
// Why: repo settings are persisted as full nested values. Preserve call
// order per repo so a slower IPC/RPC response cannot overwrite newer state.
const next = previous
? previous.catch(() => undefined).then(applyRepoUpdate)
: applyRepoUpdate()
updateRepoChains.set(repoId, next)
updateRepoChains.set(projectId, next)
const cleanup = () => {
if (updateRepoChains.get(repoId) === next) {
updateRepoChains.delete(repoId)
if (updateRepoChains.get(projectId) === next) {
updateRepoChains.delete(projectId)
}
}
void next.then(cleanup, cleanup)
return next
},
setActiveRepo: (repoId) => set({ activeRepoId: repoId }),
setActiveRepo: (projectId) => set({ activeRepoId: projectId }),
reorderRepos: async (orderedIds) => {
// Optimistically apply the new order so the sidebar updates instantly;

View File

@ -90,9 +90,11 @@ beforeEach(() => {
}
: method === 'browser.profile.list'
? { profiles: [] }
: method === 'worktree.lineageList'
? { lineage: { [env2Lineage.worktreeId]: env2Lineage } }
: {}
: method === 'projectGroup.list'
? { groups: [] }
: method === 'worktree.lineageList'
? { lineage: { [env2Lineage.worktreeId]: env2Lineage } }
: {}
return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } })
})
vi.stubGlobal('window', {
@ -154,6 +156,20 @@ describe('createSettingsSlice runtime switching', () => {
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [{ id: 'repo-env-1', path: '/env-1/repo', displayName: 'Env 1' } as never],
projectGroups: [
{
id: 'group-env-1',
name: 'Env 1 Group',
parentPath: '/env-1',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
worktreesByRepo: {
'repo-env-1': [makeWorktree({ id: 'repo-env-1::/env-1/repo', repoId: 'repo-env-1' })]
},
@ -225,6 +241,7 @@ describe('createSettingsSlice runtime switching', () => {
})
)
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['repo-env-2'])
expect(store.getState().projectGroups).toEqual([])
expect(store.getState().worktreesByRepo['repo-env-2']?.map((worktree) => worktree.id)).toEqual([
'repo-env-2::/env-2/repo'
])

View File

@ -35,6 +35,7 @@ function createOpenInApplicationId(): string {
function runtimeScopedStateReset(): Partial<AppState> {
return {
repos: [],
projectGroups: [],
activeRepoId: null,
sparsePresetsByRepo: {},
sparsePresetsLoadingByRepo: {},
@ -309,6 +310,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
// terminal, browser, and issue IDs cannot be used against the new server
// while the new environment is loading.
await get().fetchRepos()
await get().fetchProjectGroups()
await get().fetchAllWorktrees()
await get().fetchWorktreeLineage()
await get().fetchBrowserSessionProfiles()

View File

@ -238,7 +238,7 @@ function ownedEditorFileId(
// ─── Tests ────────────────────────────────────────────────────────────
describe('removeRepo cascade', () => {
describe('removeProject cascade', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApi.repos.remove.mockResolvedValue(undefined)
@ -277,7 +277,7 @@ describe('removeRepo cascade', () => {
activeTabId: 'tab1'
})
await store.getState().removeRepo('repo1')
await store.getState().removeProject('repo1')
const s = store.getState()
expect(s.repos).toEqual([])

View File

@ -334,6 +334,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
repos: [],
projectGroups: [],
sparsePresetsByRepo: {},
worktreeMeta: {},
worktreeLineageById: {},

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
clearMissingProjectGroupMemberships,
createProjectGroup,
getNextProjectGroupOrder,
getProjectGroupSubtreeIds,
normalizeProjectGroupName,
normalizeProjectGroups
} from './project-groups'
import type { Repo } from './types'
function repo(overrides: Partial<Repo>): Repo {
return {
id: overrides.id ?? 'repo-1',
path: overrides.path ?? '/repo',
displayName: overrides.displayName ?? 'repo',
badgeColor: '#999',
addedAt: 1,
kind: 'git',
...overrides
}
}
describe('project-groups', () => {
it('creates a durable project group with normalized defaults', () => {
const group = createProjectGroup({
name: ' Platform ',
parentPath: '/srv/platform',
createdFrom: 'folder-scan',
tabOrder: 3,
now: 100
})
expect(group).toMatchObject({
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 3,
isCollapsed: false,
color: null,
createdAt: 100,
updatedAt: 100
})
})
it('trims empty group names to a fallback', () => {
expect(normalizeProjectGroupName(' ', 'Existing')).toBe('Existing')
})
it('normalizes persisted groups and drops malformed entries', () => {
const groups = normalizeProjectGroups([
{ id: 'b', name: 'B', tabOrder: 2 },
{
id: 'a',
name: 'A',
tabOrder: 1,
parentGroupId: 'missing',
createdFrom: 'folder-scan',
isCollapsed: true
},
{ id: 'a', name: 'duplicate' },
{ name: 'missing id' }
])
expect(groups.map((group) => group.id)).toEqual(['a', 'b'])
expect(groups[0]).toMatchObject({
createdFrom: 'folder-scan',
isCollapsed: true,
parentGroupId: null
})
})
it('clears repo memberships whose group no longer exists', () => {
const groups = [createProjectGroup({ name: 'Known', createdFrom: 'manual', tabOrder: 0 })]
const repos = clearMissingProjectGroupMemberships(
[
repo({ id: 'known', projectGroupId: groups[0].id }),
repo({ id: 'missing', projectGroupId: 'x' })
],
groups
)
expect(repos.find((entry) => entry.id === 'known')?.projectGroupId).toBe(groups[0].id)
expect(repos.find((entry) => entry.id === 'missing')?.projectGroupId).toBeNull()
})
it('computes the next order inside a group independently from ungrouped repos', () => {
expect(
getNextProjectGroupOrder(
[
repo({ id: 'a', projectGroupId: 'g', projectGroupOrder: 2 }),
repo({ id: 'b', projectGroupId: null, projectGroupOrder: 9 })
],
'g'
)
).toBe(3)
})
it('collects descendant group ids for subtree deletion', () => {
expect(
[
...getProjectGroupSubtreeIds(
[
{ id: 'root', parentGroupId: null },
{ id: 'child', parentGroupId: 'root' },
{ id: 'grandchild', parentGroupId: 'child' },
{ id: 'sibling', parentGroupId: null }
],
'root'
)
].sort()
).toEqual(['child', 'grandchild', 'root'])
})
})

View File

@ -0,0 +1,139 @@
import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types'
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
function createProjectGroupId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (randomUUID) {
return randomUUID.call(globalThis.crypto)
}
return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string {
const trimmed = name.trim()
return trimmed.length > 0 ? trimmed : fallback
}
export function createProjectGroup(input: {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
now?: number
}): ProjectGroup {
const now = input.now ?? Date.now()
return {
id: createProjectGroupId(),
name: normalizeProjectGroupName(input.name),
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: input.tabOrder,
isCollapsed: false,
color: null,
createdAt: now,
updatedAt: now
}
}
export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
if (!Array.isArray(value)) {
return []
}
const groups: ProjectGroup[] = []
const seen = new Set<string>()
for (const candidate of value) {
if (!candidate || typeof candidate !== 'object') {
continue
}
const raw = candidate as Partial<ProjectGroup>
if (typeof raw.id !== 'string' || seen.has(raw.id)) {
continue
}
seen.add(raw.id)
const now = Date.now()
groups.push({
id: raw.id,
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
createdFrom:
raw.createdFrom === 'manual' ||
raw.createdFrom === 'folder-scan' ||
raw.createdFrom === 'migration'
? raw.createdFrom
: 'manual',
tabOrder:
typeof raw.tabOrder === 'number' && Number.isFinite(raw.tabOrder) ? raw.tabOrder : 0,
isCollapsed: raw.isCollapsed === true,
color: typeof raw.color === 'string' ? raw.color : null,
createdAt:
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
updatedAt:
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
})
}
groups.sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
const groupIds = new Set(groups.map((group) => group.id))
for (const group of groups) {
if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) {
group.parentGroupId = null
}
}
return groups
}
export function clearMissingProjectGroupMemberships(repos: Repo[], groups: ProjectGroup[]): Repo[] {
const groupIds = new Set(groups.map((group) => group.id))
return repos.map((repo) =>
repo.projectGroupId && !groupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
}
export function getProjectGroupSubtreeIds(
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[],
rootGroupId: string
): Set<string> {
const childGroupsByParentId = new Map<string, string[]>()
for (const group of groups) {
if (!group.parentGroupId) {
continue
}
childGroupsByParentId.set(group.parentGroupId, [
...(childGroupsByParentId.get(group.parentGroupId) ?? []),
group.id
])
}
const subtreeIds = new Set<string>()
const pending = [rootGroupId]
while (pending.length > 0) {
const groupId = pending.pop()!
if (subtreeIds.has(groupId)) {
continue
}
subtreeIds.add(groupId)
pending.push(...(childGroupsByParentId.get(groupId) ?? []))
}
return subtreeIds
}
export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string | null): number {
let max = -1
for (const repo of repos) {
if ((repo.projectGroupId ?? null) !== groupId) {
continue
}
const order = repo.projectGroupOrder
if (typeof order === 'number' && Number.isFinite(order)) {
max = Math.max(max, order)
}
}
return max + 1
}

View File

@ -104,10 +104,68 @@ export type Repo = {
* "what to link", the global flag is the "whether to link at all" switch.
* Undefined/empty means no symlinks are created for this repo. */
symlinkPaths?: string[]
/** Durable sidebar-only repo organization. Execution remains repo-scoped. */
projectGroupId?: string | null
/** User-authored ordering inside the project group or ungrouped bucket. */
projectGroupOrder?: number
/** Repo-specific source-control AI overrides. Missing fields inherit global settings. */
sourceControlAi?: RepoSourceControlAiOverrides
}
export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration'
export type ProjectGroup = {
id: string
name: string
parentPath: string | null
parentGroupId: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
isCollapsed: boolean
color: string | null
createdAt: number
updatedAt: number
}
export type NestedRepoScanOptions = {
maxDepth?: number
maxRepos?: number
timeoutMs?: number
}
export type NestedRepoCandidate = {
path: string
displayName: string
depth: number
}
export type NestedRepoScanResult = {
selectedPath: string
selectedPathKind: 'git_repo' | 'non_git_folder'
repos: NestedRepoCandidate[]
truncated: boolean
timedOut: boolean
durationMs: number
maxDepth: number
}
export type ProjectGroupImportMode = 'group' | 'separate'
export type ProjectGroupImportProjectResult = {
path: string
projectId?: string
status: 'imported' | 'already-known' | 'failed'
error?: string
}
export type ProjectGroupImportResult = {
group?: ProjectGroup
projects: ProjectGroupImportProjectResult[]
importedCount: number
alreadyKnownCount: number
failedCount: number
}
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
export type SetupDecision = 'inherit' | 'run' | 'skip'
export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both'
@ -2324,6 +2382,7 @@ export type LegacyPaneKeyAliasEntry = {
export type PersistedState = {
schemaVersion: number
repos: Repo[]
projectGroups: ProjectGroup[]
/** 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[]>

View File

@ -0,0 +1,129 @@
import { execFileSync } from 'child_process'
import { mkdirSync, rmSync, writeFileSync } from 'fs'
import { mkdtemp } from 'fs/promises'
import os from 'os'
import path from 'path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { ElectronApplication } from '@stablyai/playwright-test'
const tempRoots: string[] = []
async function createNestedRepoFixture(): Promise<{
parentPath: string
projectPaths: string[]
groupName: string
}> {
const parentPath = await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-folder-setup-'))
tempRoots.push(parentPath)
const repoNames = ['api-service', 'web-client']
const projectPaths = repoNames.map((name) => path.join(parentPath, name))
for (const repoPath of projectPaths) {
mkdirSync(repoPath, { recursive: true })
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], {
cwd: repoPath,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: repoPath, stdio: 'pipe' })
writeFileSync(path.join(repoPath, 'README.md'), `# ${path.basename(repoPath)}\n`)
execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' })
}
return {
parentPath,
projectPaths,
groupName: path.basename(parentPath)
}
}
test.afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
async function chooseFolderInNativeDialog(
electronApp: ElectronApplication,
folderPath: string
): Promise<void> {
await electronApp.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [selectedPath],
bookmarks: []
})
}, folderPath)
}
test.describe('Folder setup', () => {
test('imports nested repositories from the add-project dialog as a project group', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const fixture = await createNestedRepoFixture()
await chooseFolderInNativeDialog(electronApp, fixture.parentPath)
await orcaPage
.getByRole('button', { name: /Add Project/i })
.first()
.click()
const dialog = orcaPage.getByRole('dialog', { name: /Add a project/i })
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /Browse folder/i }).click()
const importDialog = orcaPage.getByRole('dialog', { name: /Import as project group/i })
await expect(
importDialog.getByRole('heading', { name: /Import as project group/i })
).toBeVisible()
await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible()
await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible()
await expect(
importDialog.getByRole('button', { name: /Import as project group/i })
).toBeEnabled()
await importDialog.getByRole('button', { name: /Import as project group/i }).click()
await expect
.poll(
() =>
orcaPage.evaluate((args) => {
const state = window.__store?.getState()
if (!state) {
return null
}
const importedRepos = state.repos
.filter((repo) => args.projectPaths.includes(repo.path))
.sort((left, right) => left.displayName.localeCompare(right.displayName))
const group = state.projectGroups.find((entry) => entry.parentPath === args.parentPath)
return {
groupName: group?.name ?? null,
repoNames: importedRepos.map((repo) => repo.displayName),
reposInCreatedGroup:
group !== undefined &&
importedRepos.every((repo) => repo.projectGroupId === group.id),
projectGroupOrders: importedRepos.map((repo) => repo.projectGroupOrder ?? null)
}
}, fixture),
{
timeout: 20_000,
message: 'nested repos were not imported into a project group'
}
)
.toEqual({
groupName: fixture.groupName,
repoNames: ['api-service', 'web-client'],
reposInCreatedGroup: true,
projectGroupOrders: [0, 1]
})
await orcaPage.evaluate(() => {
const state = window.__store?.getState()
state?.closeModal()
state?.setGroupBy('repo')
})
await expect(orcaPage.getByText(fixture.groupName)).toBeVisible()
})
})