From 76ae675a0174fdb0f59f217dfebfce9b819a3819 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 27 May 2026 16:22:03 -0700 Subject: [PATCH] Add nested repo group imports (#2866) Co-authored-by: Orca --- .../install-electron-package-binary.mjs | 112 +---- src/main/ipc/filesystem-auth.ts | 4 +- src/main/ipc/repos-create.test.ts | 2 +- src/main/ipc/repos-remote.test.ts | 228 +++++++++- src/main/ipc/repos-sparse-presets.test.ts | 2 +- src/main/ipc/repos.ts | 335 ++++++++++++++- src/main/ipc/runtime.test.ts | 23 + src/main/persistence.test.ts | 46 +- src/main/persistence.ts | 121 +++++- .../nested-repo-discovery.test.ts | 79 ++++ .../project-groups/nested-repo-discovery.ts | 172 ++++++++ .../project-groups/nested-repo-import.test.ts | 139 ++++++ src/main/project-groups/nested-repo-import.ts | 144 +++++++ src/main/runtime/orca-runtime.test.ts | 20 + src/main/runtime/orca-runtime.ts | 197 ++++++++- src/main/runtime/rpc/methods/repo.test.ts | 119 ++++- src/main/runtime/rpc/methods/repo.ts | 93 +++- src/main/skills/discovery.ts | 8 +- src/preload/api-types.ts | 37 ++ src/preload/index.ts | 31 ++ src/renderer/src/App.tsx | 2 + .../browser-automation-visibility.ts | 9 +- .../components/dashboard/useDashboardData.ts | 8 +- .../FloatingTerminalPanel.tsx | 2 +- .../components/onboarding/OnboardingFlow.tsx | 87 ++-- .../onboarding/OnboardingFooter.tsx | 70 +++ .../components/onboarding/RepoStep.test.tsx | 7 + .../src/components/onboarding/RepoStep.tsx | 142 +++++- .../onboarding/use-onboarding-flow.ts | 125 +++++- .../components/repo/NestedRepoTreePreview.tsx | 241 +++++++++++ .../components/settings/RepositoryPane.tsx | 10 +- .../src/components/settings/Settings.tsx | 4 +- .../components/sidebar/AddRepoCreateStep.tsx | 2 +- .../src/components/sidebar/AddRepoDialog.tsx | 265 ++++++++++-- .../src/components/sidebar/AddRepoSteps.tsx | 24 +- .../components/sidebar/NonGitFolderDialog.tsx | 2 +- .../sidebar/ProjectGroupDeleteDialog.tsx | 90 ++++ .../sidebar/ProjectGroupNameDialog.tsx | 114 +++++ .../components/sidebar/RemoveFolderDialog.tsx | 6 +- .../SidebarRepositoryFilterSection.tsx | 14 +- .../sidebar/WorktreeContextMenu.tsx | 90 +++- .../WorktreeList.lineage-child-card.test.ts | 2 +- .../src/components/sidebar/WorktreeList.tsx | 405 ++++++++++++++---- ...r.test.ts => project-header-color.test.ts} | 16 +- ...eader-color.ts => project-header-color.ts} | 6 +- ...-header-drag.ts => project-header-drag.ts} | 2 +- .../sidebar/worktree-list-groups.test.ts | 197 ++++++++- .../sidebar/worktree-list-groups.ts | 280 +++++++++--- .../status-bar/ResourceUsageStatusSegment.tsx | 8 +- .../mergeSnapshotAndSessions.test.ts | 2 +- .../status-bar/mergeSnapshotAndSessions.ts | 12 +- src/renderer/src/hooks/useIpcEvents.ts | 4 +- src/renderer/src/store/slices/github.ts | 8 +- .../store/slices/repos-project-groups.test.ts | 204 +++++++++ src/renderer/src/store/slices/repos.test.ts | 8 +- src/renderer/src/store/slices/repos.ts | 283 ++++++++++-- .../src/store/slices/settings.test.ts | 23 +- src/renderer/src/store/slices/settings.ts | 2 + .../slices/store-session-cascades.test.ts | 4 +- src/shared/constants.ts | 1 + src/shared/project-groups.test.ts | 115 +++++ src/shared/project-groups.ts | 139 ++++++ src/shared/types.ts | 59 +++ tests/e2e/folder-setup.spec.ts | 129 ++++++ 64 files changed, 4639 insertions(+), 496 deletions(-) create mode 100644 src/main/project-groups/nested-repo-discovery.test.ts create mode 100644 src/main/project-groups/nested-repo-discovery.ts create mode 100644 src/main/project-groups/nested-repo-import.test.ts create mode 100644 src/main/project-groups/nested-repo-import.ts create mode 100644 src/renderer/src/components/onboarding/OnboardingFooter.tsx create mode 100644 src/renderer/src/components/repo/NestedRepoTreePreview.tsx create mode 100644 src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx create mode 100644 src/renderer/src/components/sidebar/ProjectGroupNameDialog.tsx rename src/renderer/src/components/sidebar/{repo-header-color.test.ts => project-header-color.test.ts} (79%) rename src/renderer/src/components/sidebar/{repo-header-color.ts => project-header-color.ts} (81%) rename src/renderer/src/components/sidebar/{repo-header-drag.ts => project-header-drag.ts} (99%) create mode 100644 src/renderer/src/store/slices/repos-project-groups.test.ts create mode 100644 src/shared/project-groups.test.ts create mode 100644 src/shared/project-groups.ts create mode 100644 tests/e2e/folder-setup.spec.ts diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index b5eb282be..caee9139e 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -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() { diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 459fd8ac5..92e1f6d89 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -94,7 +94,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { // 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 { registeredWorktreeRoots.clear() registeredWorktreeRootsByRepo.clear() registeredWorktreeRootRepoIds.clear() - for (const { repoId, roots } of perRepoResults) { + for (const { repoId, roots } of perProjectResults) { const normalizedRoots = new Set() for (const root of roots) { normalizedRoots.add(root) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index dca66f5d3..c5a330461 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -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() }, diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index a10e0aeda..4a240f9af 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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 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 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() diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index cab508738..336d27482 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -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(), diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 06d5c4166..2ffb0fe2c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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(schema: z.ZodType, 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 { + 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 { + 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 => { + const args = parseProjectGroupIpcArgs( + ProjectGroupScanNestedArgs, + rawArgs, + 'invalid_project_group_scan_nested_args' + ) + return scanNestedReposForIpc(args) + } + ) + + ipcMain.handle( + 'projectGroups:importNested', + async (_event, rawArgs: unknown): Promise => { + 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' > > diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 68a570e28..f951091c9 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -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' } + }) + }) }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 11d33e014..a68416094 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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)) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 27940c902..d3e260ded 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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> + ): 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 diff --git a/src/main/project-groups/nested-repo-discovery.test.ts b/src/main/project-groups/nested-repo-discovery.test.ts new file mode 100644 index 000000000..c1ec7dd19 --- /dev/null +++ b/src/main/project-groups/nested-repo-discovery.test.ts @@ -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 { + const dir = await mkdtemp(join(tmpdir(), 'orca-nested-repos-')) + tempDirs.push(dir) + return dir +} + +async function makeGitRepo(path: string): Promise { + 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([]) + }) +}) diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts new file mode 100644 index 000000000..07b65974a --- /dev/null +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -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 + joinPath: (parentPath: string, childName: string) => string + basename: (path: string) => string + isGitRepoPath: (path: string) => Promise | 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 { + 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 { + try { + const marker = await stat(join(dirPath, '.git')) + return marker.isDirectory() || marker.isFile() + } catch { + return false + } +} + +async function readLocalDirectory(dirPath: string): Promise { + 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 { + 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 => { + 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 + } +} diff --git a/src/main/project-groups/nested-repo-import.test.ts b/src/main/project-groups/nested-repo-import.test.ts new file mode 100644 index 000000000..dbbaaeec4 --- /dev/null +++ b/src/main/project-groups/nested-repo-import.test.ts @@ -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']) + }) +}) diff --git a/src/main/project-groups/nested-repo-import.ts b/src/main/project-groups/nested-repo-import.ts new file mode 100644 index 000000000..c7751a67d --- /dev/null +++ b/src/main/project-groups/nested-repo-import.ts @@ -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() + + 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() + + 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 } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 44a0b0a58..df0907180 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c05407c57..112d8be58 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { + 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> + ): Promise { + 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 { + 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 { + 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 { + 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() diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 792484a5f..c18bc1c53 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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 } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index d3c999aa1..22abcf495 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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', diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 7e9f140bf..9ea8c02d8 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -262,16 +262,16 @@ export function buildSkillDiscoverySources( ) ] - const repoPaths = new Set() + const projectPaths = new Set() 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( diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6de1345b7..163b34577 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 onChanged: (callback: () => void) => () => void } + projectGroups: { + list: () => Promise + create: (args: { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom?: ProjectGroup['createdFrom'] + }) => Promise + update: (args: { + groupId: string + updates: Partial> + }) => Promise + delete: (args: { groupId: string }) => Promise + moveProject: (args: { + projectId: string + groupId: string | null + order?: number + }) => Promise + scanNested: (args: { + path: string + connectionId?: string + options?: Record + }) => Promise + importNested: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + mode: ProjectGroupImportMode + }) => Promise + } sparsePresets: { list: (args: { repoId: string }) => Promise save: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 714e5a32e..7c27dd5b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -455,6 +455,37 @@ const api = { } }, + projectGroups: { + list: (): Promise => ipcRenderer.invoke('projectGroups:list'), + create: (args: { + name: string + parentPath?: string | null + parentGroupId?: string | null + createdFrom?: 'manual' | 'folder-scan' | 'migration' + }): Promise => ipcRenderer.invoke('projectGroups:create', args), + update: (args: { groupId: string; updates: Record }): Promise => + ipcRenderer.invoke('projectGroups:update', args), + delete: (args: { groupId: string }): Promise => + ipcRenderer.invoke('projectGroups:delete', args), + moveProject: (args: { + projectId: string + groupId: string | null + order?: number + }): Promise => ipcRenderer.invoke('projectGroups:moveProject', args), + scanNested: (args: { + path: string + connectionId?: string + options?: Record + }): Promise => ipcRenderer.invoke('projectGroups:scanNested', args), + importNested: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + mode: 'group' | 'separate' + }): Promise => ipcRenderer.invoke('projectGroups:importNested', args) + }, + sparsePresets: { list: (args: { repoId: string }): Promise => ipcRenderer.invoke('sparsePresets:list', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4411ebc7a..ff4a3268a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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() diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts index ee2230327..1e2510e5b 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts @@ -1,4 +1,4 @@ -import { useMemo, useSyncExternalStore } from 'react' +import { useSyncExternalStore } from 'react' type BrowserAutomationVisibilityBridge = { acquire: (browserPageId: string) => Promise @@ -75,11 +75,8 @@ export function getBrowserAutomationVisiblePageIds(browserPageIds: readonly stri } export function useBrowserAutomationVisiblePageIds(browserPageIds: readonly string[]): Set { - 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 { diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index bb876573d..3bc98eee1 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -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, migrationUnsupportedByPtyId: Record, 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) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index b900abe70..7b19b45ab 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -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) diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index eefacda5f..b98d4d773 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -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({ 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({ {shouldShowFooter && ( -
- {shouldShowSkipToProjectSetup ? ( - - ) : ( - - )} -
- {stepIndex > 0 && ( - - )} - {(currentStep.id !== 'repo' || flow.hasExistingProject) && ( - - )} -
-
+ 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() + }} + /> )} diff --git a/src/renderer/src/components/onboarding/OnboardingFooter.tsx b/src/renderer/src/components/onboarding/OnboardingFooter.tsx new file mode 100644 index 000000000..171154948 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingFooter.tsx @@ -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 ( +
+ {shouldShowSkipToProjectSetup ? ( + + ) : ( + + )} +
+ {stepIndex > 0 && ( + + )} + {showPrimary && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/onboarding/RepoStep.test.tsx b/src/renderer/src/components/onboarding/RepoStep.test.tsx index e11db308e..45e07f1e6 100644 --- a/src/renderer/src/components/onboarding/RepoStep.test.tsx +++ b/src/renderer/src/components/onboarding/RepoStep.test.tsx @@ -8,6 +8,13 @@ function renderRepoStep(overrides: Partial> = {} void + nestedScan: NestedRepoScanResult | null + nestedSelectedPaths: Set + onNestedSelectedPathsChange: Dispatch>> + 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 ( +
+
+
+
+ +
+
+
Import as project group
+
+ {`Found ${nestedScan.repos.length} git ${ + nestedScan.repos.length === 1 ? 'repository' : 'repositories' + } in this folder.`} +
+
+ {nestedScan.selectedPath} +
+
+
+
+ + onNestedGroupNameChange(event.target.value)} + /> +
+
+ +
+ {nestedScan.truncated || nestedScan.timedOut ? ( +
+ Showing partial results from a bounded scan. +
+ ) : null} +
+ +
+ + +
+
+
+ {busyLabel && ( +
+ {busyLabel} +
+ )} + {error && ( +
+ {error} +
+ )} +
+ ) + } return (
{runtimeActive ? ( @@ -84,22 +194,30 @@ export function RepoStep({ ) : ( )} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 65c1d2e3d..9b97c0268 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -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['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(null) + const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) + const [nestedGroupName, setNestedGroupName] = useState('') const [tourStarted, setTourStarted] = useState(false) const [busyLabel, setBusyLabel] = useState(null) const [error, setError] = useState(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, diff --git a/src/renderer/src/components/repo/NestedRepoTreePreview.tsx b/src/renderer/src/components/repo/NestedRepoTreePreview.tsx new file mode 100644 index 000000000..ffe7fe175 --- /dev/null +++ b/src/renderer/src/components/repo/NestedRepoTreePreview.tsx @@ -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 + 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(null) + useEffect(() => { + if (checkboxRef.current) { + checkboxRef.current.indeterminate = isMixed + } + }, [isMixed]) + return ( + + ) +} + +export function NestedRepoTreePreview({ + scan, + selectedPaths, + onSelectedPathsChange, + disabled = false +}: { + scan: NestedRepoScanResult + selectedPaths: Set + onSelectedPathsChange: Dispatch>> + disabled?: boolean +}) { + const rows = useMemo(() => buildRows(scan), [scan]) + + return ( +
+ { + onSelectedPathsChange((previous) => { + if (previous.size === scan.repos.length) { + return new Set() + } + return new Set(scan.repos.map((repo) => repo.path)) + }) + }} + /> +
    + {rows.map((row) => + row.type === 'folder' ? ( +
  • + + + {row.name} + + + Project group + + + {repoCountLabel(row.repoCount)} + +
  • + ) : ( +
  • + +
  • + ) + )} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 18fe2b142..7443594f7 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -27,7 +27,7 @@ type RepositoryPaneProps = { hooksInspectionReady: boolean mayNeedUpdate: boolean updateRepo: (repoId: string, updates: Partial) => 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(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({ )} + {step === 'nested' && ( + + )} {step === 'setup' && (
+
+ + + + Want to import many repos at once? Select the parent folder. +
+ {/* Secondary link rather than a fourth card — create-from-scratch is a less common path than importing. See orca#763. */}
@@ -669,6 +811,75 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onPickDestination={handlePickDestination} onClone={handleClone} /> + ) : step === 'nested' && nestedScan ? ( + <> + + Import as project group + + {`Found ${nestedScan.repos.length} git ${ + nestedScan.repos.length === 1 ? 'repository' : 'repositories' + } in this folder.`} + + + +
+
+
+ +
+
+
+ Group under {nestedGroupName} +
+
+ {nestedScan.selectedPath} +
+
+
+ +
+ + setNestedGroupName(event.target.value)} + className="h-9" + /> +
+ + + {nestedScan.truncated || nestedScan.timedOut ? ( +
+ Showing partial results from a bounded scan. +
+ ) : null} +
+ +
+ + +
+
+
+ ) : step === 'create' ? ( Promise, - 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, + showNestedRepoReview?: ( + scan: NestedRepoScanResult, + selectedPath: string, + connectionId: string + ) => void ) { const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([]) const [selectedTargetId, setSelectedTargetId] = useState(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, diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index f5bee48cf..92bbb59e1 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -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] }) } diff --git a/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx b/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx new file mode 100644 index 000000000..a2610601b --- /dev/null +++ b/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx @@ -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 +} + +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 ( + { + if (!nextOpen) { + setDeleting(false) + } + onOpenChange(nextOpen) + }} + > + + + Delete Project Group + + Delete {groupName} and + ungroup its projects. + + + + + + + + + ) +} diff --git a/src/renderer/src/components/sidebar/ProjectGroupNameDialog.tsx b/src/renderer/src/components/sidebar/ProjectGroupNameDialog.tsx new file mode 100644 index 000000000..be6847070 --- /dev/null +++ b/src/renderer/src/components/sidebar/ProjectGroupNameDialog.tsx @@ -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 +} + +export function ProjectGroupNameDialog({ + open, + title, + description, + initialName, + confirmLabel, + onOpenChange, + onSubmit +}: ProjectGroupNameDialogProps): React.JSX.Element { + const inputRef = useRef(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) => { + 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 ( + + { + event.preventDefault() + inputRef.current?.focus() + inputRef.current?.select() + }} + > + + {title} + {description} + +
+
+ + setName(event.target.value)} + className="h-8 text-xs" + /> +
+ + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index f64a99331..74aae142c 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -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) => { diff --git a/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx index 5996f3d15..2d97f2cdf 100644 --- a/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.tsx @@ -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" > - + 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)} > diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index c563843d9..3fc76a009 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -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(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'} + {repo ? ( + <> + + + + New group from project + + {projectGroups.length > 0 ? ( + + + + Move to group + + + {projectGroups.map((group) => ( + handleMoveProjectToGroup(group.id)} + > + {group.name} + + ))} + + + ) : null} + {repo.projectGroupId ? ( + + + Remove from group + + ) : null} + + ) : null} {(validParentWorktreeId || lineage) && ( <> @@ -586,6 +665,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ +
) }) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 724db9444..fe239ee8b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -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() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 7af0366ba..1ef452596 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 - 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 | 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(), repoOrder, workspaceStatuses, - repoGroupOrdering, + projectGroupOrdering, worktreeLineageById, worktreeMap, true, - settings + settings, + projectGroups ).filter((r): r is Extract => 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 (
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 />
+ {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( + + + + + event.stopPropagation()} + > + { + if (row.projectGroup?.id) { + handleRenameProjectGroup(row.projectGroup.id, row.label) + } + }} + > + Rename group + + { + if (row.projectGroup?.id) { + handleDeleteProjectGroup(row.projectGroup.id, row.label) + } + }} + > + Delete group + + + + ) : null} + {row.repo && groupBy === 'repo' ? ( @@ -2152,12 +2240,57 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {getWorktreeVisibilityMenuLabel(row.repo)} ) : null} + { + if (row.repo) { + handleCreateGroupFromRepo(row.repo) + } + }} + > + + New group from project + + {projectGroups.length > 0 ? ( + + + + Move to group + + + {projectGroups.map((group) => ( + { + if (row.repo) { + handleMoveProjectToGroup(row.repo, group.id) + } + }} + > + {group.name} + + ))} + + + ) : null} + {row.repo.projectGroupId ? ( + { + if (row.repo) { + handleRemoveProjectFromGroup(row.repo) + } + }} + > + + Remove from group + + ) : null} { 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() 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(null) + const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] = + useState(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 ( - { - 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} - /> + <> + { + if (!open) { + setProjectGroupNameDialog(null) + } + }} + onSubmit={handleSubmitProjectGroupName} + /> + { + if (!open) { + setProjectGroupDeleteDialog(null) + } + }} + onConfirm={handleConfirmDeleteProjectGroup} + /> + { + 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} + /> + ) }) diff --git a/src/renderer/src/components/sidebar/repo-header-color.test.ts b/src/renderer/src/components/sidebar/project-header-color.test.ts similarity index 79% rename from src/renderer/src/components/sidebar/repo-header-color.test.ts rename to src/renderer/src/components/sidebar/project-header-color.test.ts index 697b3a3e3..6f80755de 100644 --- a/src/renderer/src/components/sidebar/repo-header-color.test.ts +++ b/src/renderer/src/components/sidebar/project-header-color.test.ts @@ -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] diff --git a/src/renderer/src/components/sidebar/repo-header-color.ts b/src/renderer/src/components/sidebar/project-header-color.ts similarity index 81% rename from src/renderer/src/components/sidebar/repo-header-color.ts rename to src/renderer/src/components/sidebar/project-header-color.ts index 35535eab5..486a27de2 100644 --- a/src/renderer/src/components/sidebar/repo-header-color.ts +++ b/src/renderer/src/components/sidebar/project-header-color.ts @@ -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) diff --git a/src/renderer/src/components/sidebar/repo-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts similarity index 99% rename from src/renderer/src/components/sidebar/repo-header-drag.ts rename to src/renderer/src/components/sidebar/project-header-drag.ts index 5e2effe45..3172f76c2 100644 --- a/src/renderer/src/components/sidebar/repo-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index e61dacaa0..57f51f1a9 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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}') }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index dd7bca443..6f7d2b254 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -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, repoOrder?: Map, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - repoGroupOrdering: RepoGroupOrdering = 'manual', + projectGroupOrdering: ProjectGroupOrdering = 'manual', lineageById: Record = {}, worktreeMap: Map = 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() + 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, + prCache: Record | 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() + 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 + ] +} diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 1d9193a5f..9ca095156 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -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 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) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index 5fd8c0bae..71adc8288 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -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']]) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 914e96512..95bf3cd05 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -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() +): UnifiedProjectGroup[] { + const repos = new Map() const seenSessionIds = new Set() 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) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 54cb6a5e2..2e0cf6f90 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() }) ) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index c9d8dbf95..d4479623d 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1724,7 +1724,7 @@ export const createGitHubSlice: StateCreator = (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 = (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 = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perRepoResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts new file mode 100644 index 000000000..916d59583 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index e8199f7b9..f3e7eb548 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index eae599e09..b9f4d0948 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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> return chains } -function getKnownRepoWorktreeIds(state: AppState, repoId: string): string[] { +function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { const ids = new Set() - 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 + fetchProjectGroups: () => Promise addRepo: () => Promise addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise addNonGitFolder: (path: string) => Promise - removeRepo: (repoId: string) => Promise - updateRepo: (repoId: string, updates: RepoUpdate) => Promise - setActiveRepo: (repoId: string | null) => void + scanNestedRepos: (path: string, connectionId?: string) => Promise + importNestedRepos: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + mode: 'group' | 'separate' + }) => Promise + createProjectGroup: (name: string) => Promise + updateProjectGroup: ( + groupId: string, + updates: Partial> + ) => Promise + deleteProjectGroup: (groupId: string) => Promise + moveProjectToGroup: ( + projectId: string, + groupId: string | null, + order?: number + ) => Promise + removeProject: (projectId: string) => Promise + updateRepo: (projectId: string, updates: RepoUpdate) => Promise + setActiveRepo: (projectId: string | null) => void reorderRepos: (orderedIds: string[]) => Promise } export const createRepoSlice: StateCreator = (set, get) => ({ repos: [], + projectGroups: [], activeRepoId: null, fetchRepos: async () => { @@ -105,9 +135,9 @@ export const createRepoSlice: StateCreator = (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 = (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( + 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( + 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 = (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() const killedPtyIds = new Set() if (target.kind === 'environment') { @@ -274,9 +491,9 @@ export const createRepoSlice: StateCreator = (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 = (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 = (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 = (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; diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 1a267f4d3..26f64c66a 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -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' ]) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 7876609ca..b984cb926 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -35,6 +35,7 @@ function createOpenInApplicationId(): string { function runtimeScopedStateReset(): Partial { return { repos: [], + projectGroups: [], activeRepoId: null, sparsePresetsByRepo: {}, sparsePresetsLoadingByRepo: {}, @@ -309,6 +310,7 @@ export const createSettingsSlice: StateCreator // 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() diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index ecd7b4c2a..49890c1d9 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -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([]) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 29e5a5bc7..2f5c13c20 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -334,6 +334,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { return { schemaVersion: SCHEMA_VERSION, repos: [], + projectGroups: [], sparsePresetsByRepo: {}, worktreeMeta: {}, worktreeLineageById: {}, diff --git a/src/shared/project-groups.test.ts b/src/shared/project-groups.test.ts new file mode 100644 index 000000000..574d17503 --- /dev/null +++ b/src/shared/project-groups.test.ts @@ -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 { + 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']) + }) +}) diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts new file mode 100644 index 000000000..f1bb76b88 --- /dev/null +++ b/src/shared/project-groups.ts @@ -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() + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object') { + continue + } + const raw = candidate as Partial + 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[], + rootGroupId: string +): Set { + const childGroupsByParentId = new Map() + for (const group of groups) { + if (!group.parentGroupId) { + continue + } + childGroupsByParentId.set(group.parentGroupId, [ + ...(childGroupsByParentId.get(group.parentGroupId) ?? []), + group.id + ]) + } + + const subtreeIds = new Set() + 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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index a83a7279f..fb693b03f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/e2e/folder-setup.spec.ts b/tests/e2e/folder-setup.spec.ts new file mode 100644 index 000000000..cefaf9a1a --- /dev/null +++ b/tests/e2e/folder-setup.spec.ts @@ -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 { + 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() + }) +})