Add nested repo group imports (#2866)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4581565e2b
commit
76ae675a01
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
|
|||
// destructive or read/write operation, so the security boundary remains
|
||||
// enforced where it matters.
|
||||
const repos = getLocalRepos(store)
|
||||
const perRepoResults = await Promise.all(
|
||||
const perProjectResults = await Promise.all(
|
||||
repos.map(async (repo) => {
|
||||
const roots: string[] = []
|
||||
try {
|
||||
|
|
@ -117,7 +117,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
|
|||
registeredWorktreeRoots.clear()
|
||||
registeredWorktreeRootsByRepo.clear()
|
||||
registeredWorktreeRootRepoIds.clear()
|
||||
for (const { repoId, roots } of perRepoResults) {
|
||||
for (const { repoId, roots } of perProjectResults) {
|
||||
const normalizedRoots = new Set<string>()
|
||||
for (const root of roots) {
|
||||
normalizedRoots.add(root)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,29 +8,42 @@ import { EventEmitter } from 'events'
|
|||
import type * as RepoModule from '../git/repo'
|
||||
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
|
||||
|
||||
const { handleMock, mockStore, mockGitProvider, mockMultiplexer, gitSpawnMock } = vi.hoisted(
|
||||
() => ({
|
||||
handleMock: vi.fn(),
|
||||
mockStore: {
|
||||
getRepos: vi.fn().mockReturnValue([]),
|
||||
addRepo: vi.fn(),
|
||||
removeRepo: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
updateRepo: vi.fn(),
|
||||
getSshTarget: vi.fn()
|
||||
},
|
||||
mockGitProvider: {
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
},
|
||||
mockMultiplexer: {
|
||||
request: vi.fn(),
|
||||
notify: vi.fn()
|
||||
},
|
||||
gitSpawnMock: vi.fn()
|
||||
})
|
||||
)
|
||||
const {
|
||||
handleMock,
|
||||
mockStore,
|
||||
mockGitProvider,
|
||||
mockFilesystemProvider,
|
||||
mockMultiplexer,
|
||||
gitSpawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
mockStore: {
|
||||
getRepos: vi.fn().mockReturnValue([]),
|
||||
addRepo: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
updateRepo: vi.fn(),
|
||||
getProjectGroups: vi.fn().mockReturnValue([]),
|
||||
createProjectGroup: vi.fn(),
|
||||
updateProjectGroup: vi.fn(),
|
||||
deleteProjectGroup: vi.fn(),
|
||||
moveProjectToGroup: vi.fn(),
|
||||
getSshTarget: vi.fn()
|
||||
},
|
||||
mockGitProvider: {
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
},
|
||||
mockFilesystemProvider: {
|
||||
readDir: vi.fn().mockResolvedValue([])
|
||||
},
|
||||
mockMultiplexer: {
|
||||
request: vi.fn(),
|
||||
notify: vi.fn()
|
||||
},
|
||||
gitSpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
|
|
@ -76,6 +89,15 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
|||
})
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1') {
|
||||
return mockFilesystemProvider
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1') {
|
||||
|
|
@ -87,6 +109,164 @@ vi.mock('./ssh', () => ({
|
|||
|
||||
import { registerRepoHandlers } from './repos'
|
||||
|
||||
describe('projectGroups IPC validation', () => {
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
mockWindow.webContents.send.mockReset()
|
||||
mockStore.createProjectGroup.mockReset()
|
||||
mockStore.updateProjectGroup.mockReset()
|
||||
mockStore.deleteProjectGroup.mockReset()
|
||||
mockStore.moveProjectToGroup.mockReset()
|
||||
mockStore.getRepos.mockReset()
|
||||
mockStore.getRepos.mockReturnValue([])
|
||||
mockFilesystemProvider.readDir.mockReset()
|
||||
mockFilesystemProvider.readDir.mockResolvedValue([])
|
||||
mockGitProvider.isGitRepoAsync.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
|
||||
mockMultiplexer.notify.mockReset()
|
||||
mockMultiplexer.request.mockReset()
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
})
|
||||
|
||||
it('rejects malformed local project group create arguments before persistence', () => {
|
||||
expect(() =>
|
||||
handlers.get('projectGroups:create')!(null, { name: 123, createdFrom: 'unexpected' })
|
||||
).toThrow('invalid_project_group_create_args')
|
||||
|
||||
expect(mockStore.createProjectGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed local project group update arguments before persistence', () => {
|
||||
expect(() =>
|
||||
handlers.get('projectGroups:update')!(null, {
|
||||
groupId: 'group-1',
|
||||
updates: { isCollapsed: 'yes' }
|
||||
})
|
||||
).toThrow('invalid_project_group_update_args')
|
||||
|
||||
expect(mockStore.updateProjectGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans nested repositories over a connected SSH filesystem', async () => {
|
||||
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
|
||||
isRepo: path === '/srv/platform/api',
|
||||
rootPath: null
|
||||
}))
|
||||
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
|
||||
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
|
||||
)
|
||||
|
||||
const result = await handlers.get('projectGroups:scanNested')!(null, {
|
||||
path: '/srv/platform',
|
||||
connectionId: 'conn-1'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
selectedPath: '/srv/platform',
|
||||
selectedPathKind: 'non_git_folder',
|
||||
repos: [{ path: '/srv/platform/api', displayName: 'api' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects local nested scans with relative paths', async () => {
|
||||
await expect(
|
||||
handlers.get('projectGroups:scanNested')!(null, {
|
||||
path: 'relative/project'
|
||||
})
|
||||
).rejects.toThrow('Repo path must be an absolute path')
|
||||
})
|
||||
|
||||
it('imports nested SSH repositories with connection-scoped repo entries', async () => {
|
||||
const group = {
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/srv/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
mockStore.createProjectGroup.mockReturnValue(group)
|
||||
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
|
||||
isRepo: path === '/srv/platform/api',
|
||||
rootPath: null
|
||||
}))
|
||||
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
|
||||
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
|
||||
)
|
||||
|
||||
const result = await handlers.get('projectGroups:importNested')!(null, {
|
||||
parentPath: '/srv/platform',
|
||||
groupName: 'Platform',
|
||||
projectPaths: ['/srv/platform/api'],
|
||||
connectionId: 'conn-1',
|
||||
mode: 'group'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ importedCount: 1, failedCount: 0 })
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/srv/platform/api',
|
||||
connectionId: 'conn-1',
|
||||
projectGroupId: group.id
|
||||
})
|
||||
)
|
||||
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
|
||||
rootPath: '/srv/platform/api'
|
||||
})
|
||||
})
|
||||
|
||||
it('sanitizes unexpected nested import errors before returning results', async () => {
|
||||
const group = {
|
||||
id: 'group-1',
|
||||
name: 'Platform',
|
||||
parentPath: '/srv/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
mockStore.createProjectGroup.mockReturnValue(group)
|
||||
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
|
||||
isRepo: path === '/srv/platform/api',
|
||||
rootPath: null
|
||||
}))
|
||||
mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) =>
|
||||
dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : []
|
||||
)
|
||||
mockStore.addRepo.mockImplementationOnce(() => {
|
||||
throw new Error('secret backend path /srv/platform/api')
|
||||
})
|
||||
|
||||
const result = (await handlers.get('projectGroups:importNested')!(null, {
|
||||
parentPath: '/srv/platform',
|
||||
groupName: 'Platform',
|
||||
projectPaths: ['/srv/platform/api'],
|
||||
connectionId: 'conn-1',
|
||||
mode: 'group'
|
||||
})) as { projects: { error?: string }[] }
|
||||
|
||||
expect(result.projects[0].error).toBe('Repository could not be imported')
|
||||
})
|
||||
})
|
||||
|
||||
describe('repos:getGitUsername', () => {
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
|
||||
const mockWindow = {
|
||||
|
|
@ -171,6 +351,8 @@ describe('repos:addRemote', () => {
|
|||
mockStore.addRepo.mockReset()
|
||||
mockStore.getSshTarget.mockReset()
|
||||
mockStore.updateRepo.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
|
||||
mockMultiplexer.request.mockReset()
|
||||
mockMultiplexer.notify.mockReset()
|
||||
gitSpawnMock.mockReset()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ boundary. Splitting by line count would scatter tightly coupled repo behavior. *
|
|||
import type { BrowserWindow } from 'electron'
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectGroupImportResult,
|
||||
NestedRepoScanResult,
|
||||
BaseRefDefaultResult,
|
||||
SparsePreset
|
||||
} from '../../shared/types'
|
||||
|
|
@ -19,7 +23,14 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
|||
import type { ChildProcess } from 'child_process'
|
||||
import { access, mkdir, readdir, rm } from 'fs/promises'
|
||||
import { gitExecFileAsync, gitSpawn } from '../git/runner'
|
||||
import { basename, isAbsolute, join } from 'path'
|
||||
import { basename, isAbsolute, join, posix } from 'path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { getNextProjectGroupOrder } from '../../shared/project-groups'
|
||||
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
|
||||
import {
|
||||
createNestedProjectGroupResolver,
|
||||
resolveNestedRepoSelection
|
||||
} from '../project-groups/nested-repo-import'
|
||||
import {
|
||||
isGitRepo,
|
||||
getGitUsername,
|
||||
|
|
@ -34,6 +45,7 @@ import {
|
|||
searchBaseRefDetails
|
||||
} from '../git/repo'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
|
|
@ -69,6 +81,132 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean): void {
|
|||
let activeCloneProc: ChildProcess | null = null
|
||||
let activeClonePath: string | null = null
|
||||
|
||||
const ProjectGroupCreateArgs = z.object({
|
||||
name: z.string().min(1),
|
||||
parentPath: z.string().nullable().optional(),
|
||||
parentGroupId: z.string().nullable().optional(),
|
||||
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
|
||||
})
|
||||
|
||||
const ProjectGroupUpdateArgs = z.object({
|
||||
groupId: z.string().min(1),
|
||||
updates: z.object({
|
||||
name: z.string().optional(),
|
||||
isCollapsed: z.boolean().optional(),
|
||||
tabOrder: z.number().finite().optional(),
|
||||
color: z.string().nullable().optional()
|
||||
})
|
||||
})
|
||||
|
||||
const ProjectGroupSelectorArgs = z.object({
|
||||
groupId: z.string().min(1)
|
||||
})
|
||||
|
||||
const ProjectGroupMoveProjectArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
groupId: z.string().nullable(),
|
||||
order: z.number().finite().optional()
|
||||
})
|
||||
|
||||
const ProjectGroupScanNestedArgs = z.object({
|
||||
path: z.string().min(1),
|
||||
connectionId: z.string().min(1).optional(),
|
||||
options: z.unknown().optional()
|
||||
})
|
||||
|
||||
const ProjectGroupImportNestedArgs = z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
parentPath: z.string().min(1),
|
||||
groupName: z.string().min(1),
|
||||
projectPaths: z.array(z.string()),
|
||||
connectionId: z.string().min(1).optional(),
|
||||
mode: z.literal('group')
|
||||
}),
|
||||
z.object({
|
||||
parentPath: z.string().min(1),
|
||||
groupName: z.string().optional().default(''),
|
||||
projectPaths: z.array(z.string()),
|
||||
connectionId: z.string().min(1).optional(),
|
||||
mode: z.literal('separate')
|
||||
})
|
||||
])
|
||||
|
||||
function parseProjectGroupIpcArgs<T>(schema: z.ZodType<T>, value: unknown, errorCode: string): T {
|
||||
const result = schema.safeParse(value)
|
||||
if (result.success) {
|
||||
return result.data
|
||||
}
|
||||
throw new Error(errorCode)
|
||||
}
|
||||
|
||||
function validateNestedRepoScanRoot(path: string, connectionId?: string): void {
|
||||
if (connectionId) {
|
||||
return
|
||||
}
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error('Repo path must be an absolute path')
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNestedRepoImportError(context: string, error: unknown): string {
|
||||
console.warn(`[project-groups] ${context}`, error)
|
||||
return 'Repository could not be imported'
|
||||
}
|
||||
|
||||
async function resolveSshProjectGroupPath(connectionId: string, path: string): Promise<string> {
|
||||
if (path === '~' || path === '~/' || path.startsWith('~/')) {
|
||||
const mux = getActiveMultiplexer(connectionId)
|
||||
if (mux) {
|
||||
try {
|
||||
const result = (await mux.request('session.resolveHome', { path })) as {
|
||||
resolvedPath: string
|
||||
}
|
||||
return result.resolvedPath
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function scanNestedReposForIpc(args: {
|
||||
path: string
|
||||
connectionId?: string
|
||||
options?: unknown
|
||||
}): Promise<NestedRepoScanResult> {
|
||||
validateNestedRepoScanRoot(args.path, args.connectionId)
|
||||
if (!args.connectionId) {
|
||||
return scanNestedRepos({ path: args.path, options: args.options })
|
||||
}
|
||||
const gitProvider = getSshGitProvider(args.connectionId)
|
||||
const fsProvider = getSshFilesystemProvider(args.connectionId)
|
||||
if (!gitProvider || !fsProvider) {
|
||||
throw new Error('ssh_connection_unavailable')
|
||||
}
|
||||
const resolvedPath = await resolveSshProjectGroupPath(args.connectionId, args.path)
|
||||
return scanNestedRepos({
|
||||
path: resolvedPath,
|
||||
options: args.options,
|
||||
filesystem: {
|
||||
readDirectory: async (dirPath) =>
|
||||
(await fsProvider.readDir(dirPath)).map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory
|
||||
})),
|
||||
joinPath: (parentPath, childName) => posix.join(parentPath, childName),
|
||||
basename: (path) => posix.basename(path),
|
||||
isGitRepoPath: async (path) => {
|
||||
try {
|
||||
return (await gitProvider.isGitRepoAsync(path)).isRepo
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
// Remove any previously registered handlers so we can re-register them
|
||||
// (e.g. when macOS re-activates the app and creates a new window).
|
||||
|
|
@ -77,6 +215,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
ipcMain.removeHandler('repos:remove')
|
||||
ipcMain.removeHandler('repos:reorder')
|
||||
ipcMain.removeHandler('repos:update')
|
||||
ipcMain.removeHandler('projectGroups:list')
|
||||
ipcMain.removeHandler('projectGroups:create')
|
||||
ipcMain.removeHandler('projectGroups:update')
|
||||
ipcMain.removeHandler('projectGroups:delete')
|
||||
ipcMain.removeHandler('projectGroups:moveProject')
|
||||
ipcMain.removeHandler('projectGroups:scanNested')
|
||||
ipcMain.removeHandler('projectGroups:importNested')
|
||||
ipcMain.removeHandler('repos:pickFolder')
|
||||
ipcMain.removeHandler('repos:pickDirectory')
|
||||
ipcMain.removeHandler('repos:clone')
|
||||
|
|
@ -95,6 +240,190 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
return store.getRepos()
|
||||
})
|
||||
|
||||
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
|
||||
|
||||
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupCreateArgs,
|
||||
rawArgs,
|
||||
'invalid_project_group_create_args'
|
||||
)
|
||||
const group = store.createProjectGroup({
|
||||
name: args.name,
|
||||
parentPath: args.parentPath ?? null,
|
||||
parentGroupId: args.parentGroupId ?? null,
|
||||
createdFrom: args.createdFrom ?? 'manual'
|
||||
})
|
||||
notifyReposChanged(mainWindow)
|
||||
return group
|
||||
})
|
||||
|
||||
ipcMain.handle('projectGroups:update', (_event, rawArgs: unknown): ProjectGroup | null => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupUpdateArgs,
|
||||
rawArgs,
|
||||
'invalid_project_group_update_args'
|
||||
)
|
||||
const updated = store.updateProjectGroup(args.groupId, args.updates)
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
ipcMain.handle('projectGroups:delete', (_event, rawArgs: unknown): boolean => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupSelectorArgs,
|
||||
rawArgs,
|
||||
'invalid_project_group_delete_args'
|
||||
)
|
||||
const deleted = store.deleteProjectGroup(args.groupId)
|
||||
if (deleted) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
ipcMain.handle('projectGroups:moveProject', (_event, rawArgs: unknown): Repo | null => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupMoveProjectArgs,
|
||||
rawArgs,
|
||||
'invalid_project_group_move_repo_args'
|
||||
)
|
||||
const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order)
|
||||
if (moved) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
return moved
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'projectGroups:scanNested',
|
||||
async (_event, rawArgs: unknown): Promise<NestedRepoScanResult> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupScanNestedArgs,
|
||||
rawArgs,
|
||||
'invalid_project_group_scan_nested_args'
|
||||
)
|
||||
return scanNestedReposForIpc(args)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'projectGroups:importNested',
|
||||
async (_event, rawArgs: unknown): Promise<ProjectGroupImportResult> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectGroupImportNestedArgs,
|
||||
rawArgs,
|
||||
'invalid_project_group_import_nested_args'
|
||||
)
|
||||
const requestedPaths = args.projectPaths
|
||||
const scan = await scanNestedReposForIpc({
|
||||
path: args.parentPath,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
const selection = resolveNestedRepoSelection({ scan, projectPaths: requestedPaths })
|
||||
const groupResolver = createNestedProjectGroupResolver({
|
||||
parentPath: scan.selectedPath,
|
||||
groupName: args.groupName ?? '',
|
||||
mode: args.mode,
|
||||
createGroup: (input) => store.createProjectGroup(input)
|
||||
})
|
||||
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
|
||||
(repoPath) => ({
|
||||
path: repoPath,
|
||||
status: 'failed',
|
||||
error: 'Repository was not found in the nested repo scan result'
|
||||
})
|
||||
)
|
||||
|
||||
for (const repoPath of selection.selectedPaths) {
|
||||
try {
|
||||
if (args.connectionId) {
|
||||
const gitProvider = getSshGitProvider(args.connectionId)
|
||||
if (!gitProvider || !(await gitProvider.isGitRepoAsync(repoPath)).isRepo) {
|
||||
results.push({
|
||||
path: repoPath,
|
||||
status: 'failed',
|
||||
error: 'Not a valid git repository'
|
||||
})
|
||||
continue
|
||||
}
|
||||
} else if (!isGitRepo(repoPath)) {
|
||||
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
|
||||
continue
|
||||
}
|
||||
const existing = store
|
||||
.getRepos()
|
||||
.find(
|
||||
(repo) =>
|
||||
(repo.connectionId ?? null) === (args.connectionId ?? null) &&
|
||||
normalizeRuntimePathForComparison(repo.path) ===
|
||||
normalizeRuntimePathForComparison(repoPath)
|
||||
)
|
||||
const group = groupResolver.getGroupForRepo(repoPath)
|
||||
if (existing) {
|
||||
if (group) {
|
||||
store.moveProjectToGroup(existing.id, group.id)
|
||||
}
|
||||
results.push({ path: repoPath, projectId: existing.id, status: 'already-known' })
|
||||
continue
|
||||
}
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: repoPath,
|
||||
displayName: getRepoName(repoPath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
...(group
|
||||
? {
|
||||
projectGroupId: group.id,
|
||||
projectGroupOrder: getNextProjectGroupOrder(store.getRepos(), group.id)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
store.addRepo(repo)
|
||||
if (args.connectionId) {
|
||||
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
|
||||
rootPath: repoPath
|
||||
})
|
||||
}
|
||||
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
|
||||
emitRepoAdded('folder_picker', false)
|
||||
} catch (error) {
|
||||
results.push({
|
||||
path: repoPath,
|
||||
status: 'failed',
|
||||
error: sanitizeNestedRepoImportError('Failed to import nested repository', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const importedCount = results.filter((entry) => entry.status === 'imported').length
|
||||
const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length
|
||||
const failedCount = results.filter((entry) => entry.status === 'failed').length
|
||||
if (importedCount + alreadyKnownCount === 0) {
|
||||
for (const group of groupResolver.getCreatedGroups().reverse()) {
|
||||
store.deleteProjectGroup(group.id)
|
||||
}
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
const rootGroup = groupResolver.getRootGroup()
|
||||
return {
|
||||
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
|
||||
projects: results,
|
||||
importedCount,
|
||||
alreadyKnownCount,
|
||||
failedCount
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'repos:add',
|
||||
async (
|
||||
|
|
@ -473,7 +802,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
)
|
||||
|
||||
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
|
||||
store.removeRepo(args.repoId)
|
||||
store.removeProject(args.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
})
|
||||
|
|
@ -497,6 +826,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import type {
|
||||
PersistedState,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
SparsePreset,
|
||||
WorktreeMeta,
|
||||
WorktreeLineage,
|
||||
|
|
@ -94,6 +95,14 @@ import {
|
|||
} from '../shared/workspace-statuses'
|
||||
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership'
|
||||
import { sanitizeRepoIcon } from '../shared/repo-icon'
|
||||
import {
|
||||
clearMissingProjectGroupMemberships,
|
||||
createProjectGroup,
|
||||
getNextProjectGroupOrder,
|
||||
getProjectGroupSubtreeIds,
|
||||
normalizeProjectGroupName,
|
||||
normalizeProjectGroups
|
||||
} from '../shared/project-groups'
|
||||
import {
|
||||
mergeLegacyCommitMessageAiIntoSourceControlAi,
|
||||
normalizeRepoSourceControlAiOverrides,
|
||||
|
|
@ -1525,6 +1534,7 @@ export class Store {
|
|||
result = {
|
||||
...defaults,
|
||||
...parsed,
|
||||
projectGroups: normalizeProjectGroups(parsed.projectGroups),
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
|
|
@ -1819,6 +1829,7 @@ export class Store {
|
|||
|
||||
result = {
|
||||
...result,
|
||||
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
|
||||
workspaceSession: pruneWorkspaceSessionBrowserHistory(
|
||||
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
|
||||
)
|
||||
|
|
@ -2049,6 +2060,95 @@ export class Store {
|
|||
return repo ? this.hydrateRepo(repo) : undefined
|
||||
}
|
||||
|
||||
getProjectGroups(): ProjectGroup[] {
|
||||
return [...(this.state.projectGroups ?? [])].sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
createProjectGroup(input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroup['createdFrom']
|
||||
}): ProjectGroup {
|
||||
const maxOrder = Math.max(
|
||||
-1,
|
||||
...(this.state.projectGroups ?? []).map((group) => group.tabOrder)
|
||||
)
|
||||
const group = createProjectGroup({
|
||||
...input,
|
||||
tabOrder: maxOrder + 1
|
||||
})
|
||||
this.state.projectGroups = [...(this.state.projectGroups ?? []), group]
|
||||
this.scheduleSave()
|
||||
return group
|
||||
}
|
||||
|
||||
updateProjectGroup(
|
||||
groupId: string,
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
): ProjectGroup | null {
|
||||
const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId)
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
if (updates.name !== undefined) {
|
||||
group.name = normalizeProjectGroupName(updates.name, group.name)
|
||||
}
|
||||
if (updates.isCollapsed !== undefined) {
|
||||
group.isCollapsed = updates.isCollapsed
|
||||
}
|
||||
if (updates.tabOrder !== undefined && Number.isFinite(updates.tabOrder)) {
|
||||
group.tabOrder = updates.tabOrder
|
||||
}
|
||||
if (updates.color !== undefined) {
|
||||
group.color = typeof updates.color === 'string' ? updates.color : null
|
||||
}
|
||||
group.updatedAt = Date.now()
|
||||
this.scheduleSave()
|
||||
return group
|
||||
}
|
||||
|
||||
deleteProjectGroup(groupId: string): boolean {
|
||||
const before = this.state.projectGroups?.length ?? 0
|
||||
const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId)
|
||||
this.state.projectGroups = (this.state.projectGroups ?? []).filter(
|
||||
(group) => !deletedGroupIds.has(group.id)
|
||||
)
|
||||
if ((this.state.projectGroups?.length ?? 0) === before) {
|
||||
return false
|
||||
}
|
||||
// Why: groups are sidebar organization only. Deleting one must not delete
|
||||
// repos or worktrees, so contained repos from the full subtree are ungrouped.
|
||||
this.state.repos = this.state.repos.map((repo) =>
|
||||
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null {
|
||||
const repo = this.state.repos.find((entry) => entry.id === repoId)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
const normalizedGroupId =
|
||||
groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId)
|
||||
? groupId
|
||||
: null
|
||||
const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId)
|
||||
repo.projectGroupId = normalizedGroupId
|
||||
repo.projectGroupOrder =
|
||||
typeof order === 'number' && Number.isFinite(order)
|
||||
? order
|
||||
: getNextProjectGroupOrder(siblingRepos, normalizedGroupId)
|
||||
this.scheduleSave()
|
||||
return this.hydrateRepo(repo)
|
||||
}
|
||||
|
||||
addRepo(repo: Repo): void {
|
||||
this.state.repos.push(repo)
|
||||
this.scheduleSave()
|
||||
|
|
@ -2086,7 +2186,7 @@ export class Store {
|
|||
return true
|
||||
}
|
||||
|
||||
removeRepo(id: string): void {
|
||||
removeProject(id: string): void {
|
||||
this.state.repos = this.state.repos.filter((r) => r.id !== id)
|
||||
// Why: presets are repo-scoped, so removing the repo means the presets
|
||||
// can never be referenced again — drop them with the parent.
|
||||
|
|
@ -2121,6 +2221,8 @@ export class Store {
|
|||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
|
|
@ -2130,6 +2232,23 @@ export class Store {
|
|||
return null
|
||||
}
|
||||
const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates)
|
||||
if ('projectGroupId' in sanitizedUpdates) {
|
||||
const nextGroupId = sanitizedUpdates.projectGroupId
|
||||
if (
|
||||
typeof nextGroupId !== 'string' ||
|
||||
nextGroupId.trim().length === 0 ||
|
||||
!this.state.projectGroups.some((group) => group.id === nextGroupId)
|
||||
) {
|
||||
sanitizedUpdates.projectGroupId = null
|
||||
}
|
||||
}
|
||||
if (
|
||||
'projectGroupOrder' in sanitizedUpdates &&
|
||||
(typeof sanitizedUpdates.projectGroupOrder !== 'number' ||
|
||||
!Number.isFinite(sanitizedUpdates.projectGroupOrder))
|
||||
) {
|
||||
delete sanitizedUpdates.projectGroupOrder
|
||||
}
|
||||
const externalWorktreeVisibilityLegacy =
|
||||
'externalWorktreeVisibility' in sanitizedUpdates &&
|
||||
repo.externalWorktreeVisibilityLegacy === undefined
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanNestedRepos } from './nested-repo-discovery'
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'orca-nested-repos-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function makeGitRepo(path: string): Promise<void> {
|
||||
await mkdir(join(path, '.git'), { recursive: true })
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
describe('scanNestedRepos', () => {
|
||||
it('returns child repos for a non-git parent', async () => {
|
||||
const root = await tempRoot()
|
||||
await mkdir(join(root, 'auth-service'), { recursive: true })
|
||||
await mkdir(join(root, 'billing-service'), { recursive: true })
|
||||
await makeGitRepo(join(root, 'auth-service'))
|
||||
await makeGitRepo(join(root, 'billing-service'))
|
||||
|
||||
const result = await scanNestedRepos({ path: root })
|
||||
|
||||
expect(result.selectedPathKind).toBe('non_git_folder')
|
||||
expect(result.repos.map((repo) => repo.displayName)).toEqual([
|
||||
'auth-service',
|
||||
'billing-service'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not scan inside an already discovered repo', async () => {
|
||||
const root = await tempRoot()
|
||||
await mkdir(join(root, 'service', 'nested'), { recursive: true })
|
||||
await makeGitRepo(join(root, 'service'))
|
||||
await makeGitRepo(join(root, 'service', 'nested'))
|
||||
|
||||
const result = await scanNestedRepos({ path: root })
|
||||
|
||||
expect(result.repos.map((repo) => repo.displayName)).toEqual(['service'])
|
||||
})
|
||||
|
||||
it('skips heavy directories and respects result caps', async () => {
|
||||
const root = await tempRoot()
|
||||
await mkdir(join(root, 'node_modules', 'ignored'), { recursive: true })
|
||||
await mkdir(join(root, 'one'), { recursive: true })
|
||||
await mkdir(join(root, 'two'), { recursive: true })
|
||||
await makeGitRepo(join(root, 'node_modules', 'ignored'))
|
||||
await makeGitRepo(join(root, 'one'))
|
||||
await makeGitRepo(join(root, 'two'))
|
||||
|
||||
const result = await scanNestedRepos({ path: root, options: { maxRepos: 1 } })
|
||||
|
||||
expect(result.repos[0].displayName).toBe('one')
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a selected git repo as the existing repo path', async () => {
|
||||
const root = await tempRoot()
|
||||
await makeGitRepo(root)
|
||||
await mkdir(join(root, 'child'), { recursive: true })
|
||||
await makeGitRepo(join(root, 'child'))
|
||||
await writeFile(join(root, 'README.md'), '')
|
||||
|
||||
const result = await scanNestedRepos({ path: root })
|
||||
|
||||
expect(result.selectedPathKind).toBe('git_repo')
|
||||
expect(result.repos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import type {
|
||||
NestedRepoCandidate,
|
||||
NestedRepoScanOptions,
|
||||
NestedRepoScanResult
|
||||
} from '../../shared/types'
|
||||
import { isGitRepo } from '../git/repo'
|
||||
|
||||
type NestedRepoDirectoryEntry = {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
type NestedRepoScanFilesystem = {
|
||||
readDirectory: (dirPath: string) => Promise<NestedRepoDirectoryEntry[]>
|
||||
joinPath: (parentPath: string, childName: string) => string
|
||||
basename: (path: string) => string
|
||||
isGitRepoPath: (path: string) => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 3
|
||||
const DEFAULT_MAX_REPOS = 100
|
||||
const DEFAULT_TIMEOUT_MS = 8_000
|
||||
|
||||
const SKIPPED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'.cache',
|
||||
'vendor',
|
||||
'__pycache__',
|
||||
'.turbo',
|
||||
'.parcel-cache'
|
||||
])
|
||||
|
||||
function normalizeScanOptions(options: unknown): Required<NestedRepoScanOptions> {
|
||||
const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {}
|
||||
return {
|
||||
maxDepth:
|
||||
typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth)
|
||||
? Math.max(1, Math.min(8, Math.floor(raw.maxDepth)))
|
||||
: DEFAULT_MAX_DEPTH,
|
||||
maxRepos:
|
||||
typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos)
|
||||
? Math.max(1, Math.min(500, Math.floor(raw.maxRepos)))
|
||||
: DEFAULT_MAX_REPOS,
|
||||
timeoutMs:
|
||||
typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs)
|
||||
? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs)))
|
||||
: DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipDirectory(name: string, depth: number): boolean {
|
||||
if (SKIPPED_DIRS.has(name)) {
|
||||
return true
|
||||
}
|
||||
return depth > 0 && name.startsWith('.')
|
||||
}
|
||||
|
||||
async function hasGitMarker(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const marker = await stat(join(dirPath, '.git'))
|
||||
return marker.isDirectory() || marker.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalDirectory(dirPath: string): Promise<NestedRepoDirectoryEntry[]> {
|
||||
const entries = await readdir(dirPath)
|
||||
const result: NestedRepoDirectoryEntry[] = []
|
||||
for (const name of entries) {
|
||||
const childStat = await stat(join(dirPath, name)).catch(() => null)
|
||||
result.push({ name, isDirectory: childStat?.isDirectory() === true })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function scanNestedRepos(args: {
|
||||
path: string
|
||||
options?: unknown
|
||||
filesystem?: NestedRepoScanFilesystem
|
||||
}): Promise<NestedRepoScanResult> {
|
||||
const startedAt = Date.now()
|
||||
const options = normalizeScanOptions(args.options)
|
||||
const repos: NestedRepoCandidate[] = []
|
||||
let truncated = false
|
||||
let timedOut = false
|
||||
const filesystem = args.filesystem ?? {
|
||||
readDirectory: readLocalDirectory,
|
||||
joinPath: join,
|
||||
basename,
|
||||
isGitRepoPath: async (path: string) => isGitRepo(path) || (await hasGitMarker(path))
|
||||
}
|
||||
|
||||
if (await filesystem.isGitRepoPath(args.path)) {
|
||||
return {
|
||||
selectedPath: args.path,
|
||||
selectedPathKind: 'git_repo',
|
||||
repos: [],
|
||||
truncated: false,
|
||||
timedOut: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
maxDepth: options.maxDepth
|
||||
}
|
||||
}
|
||||
|
||||
const visit = async (dirPath: string, depth: number): Promise<void> => {
|
||||
if (repos.length >= options.maxRepos) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
if (Date.now() - startedAt > options.timeoutMs) {
|
||||
timedOut = true
|
||||
return
|
||||
}
|
||||
if (depth > options.maxDepth) {
|
||||
return
|
||||
}
|
||||
|
||||
let entries: NestedRepoDirectoryEntry[]
|
||||
try {
|
||||
entries = await filesystem.readDirectory(dirPath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const dirs = entries
|
||||
.filter((entry) => entry.isDirectory)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
for (const entry of dirs) {
|
||||
const name = entry.name
|
||||
if (repos.length >= options.maxRepos) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
if (Date.now() - startedAt > options.timeoutMs) {
|
||||
timedOut = true
|
||||
return
|
||||
}
|
||||
if (shouldSkipDirectory(name, depth)) {
|
||||
continue
|
||||
}
|
||||
const childPath = filesystem.joinPath(dirPath, name)
|
||||
if (await filesystem.isGitRepoPath(childPath)) {
|
||||
repos.push({
|
||||
path: childPath,
|
||||
displayName: filesystem.basename(childPath),
|
||||
depth: depth + 1
|
||||
})
|
||||
// Project Groups organize sibling repos; nested repos stay hidden until a
|
||||
// later UI can explain and select submodule-style layouts explicitly.
|
||||
continue
|
||||
}
|
||||
await visit(childPath, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
await visit(args.path, 0)
|
||||
return {
|
||||
selectedPath: args.path,
|
||||
selectedPathKind: 'non_git_folder',
|
||||
repos,
|
||||
truncated,
|
||||
timedOut,
|
||||
durationMs: Date.now() - startedAt,
|
||||
maxDepth: options.maxDepth
|
||||
}
|
||||
}
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import type { NestedRepoScanResult, ProjectGroup, ProjectGroupImportMode } from '../../shared/types'
|
||||
import {
|
||||
normalizeRuntimePathForComparison,
|
||||
relativePathInsideRoot
|
||||
} from '../../shared/cross-platform-path'
|
||||
|
||||
type CreateGroupInput = {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroup['createdFrom']
|
||||
}
|
||||
|
||||
type NestedProjectGroupResolver = {
|
||||
getGroupForRepo: (repoPath: string) => ProjectGroup | undefined
|
||||
getRootGroup: () => ProjectGroup | undefined
|
||||
getCreatedGroups: () => ProjectGroup[]
|
||||
}
|
||||
|
||||
export type ResolvedNestedRepoSelection = {
|
||||
selectedPaths: string[]
|
||||
rejectedPaths: string[]
|
||||
}
|
||||
|
||||
function trimPathSeparators(path: string): string {
|
||||
if (path === '/' || /^[A-Za-z]:[\\/]?$/.test(path)) {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
if (/^\/\/[^/]+\/[^/]+\/?$/.test(path.replace(/\\/g, '/'))) {
|
||||
return path.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
}
|
||||
return path.replace(/[\\/]+$/g, '')
|
||||
}
|
||||
|
||||
function splitPath(path: string): string[] {
|
||||
return trimPathSeparators(path)
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function joinPath(parentPath: string, segments: readonly string[]): string {
|
||||
const trimmedParent = trimPathSeparators(parentPath)
|
||||
const separator = trimmedParent.includes('\\') && !trimmedParent.includes('/') ? '\\' : '/'
|
||||
return segments.length === 0
|
||||
? trimmedParent
|
||||
: trimmedParent === '/'
|
||||
? `/${segments.join('/')}`
|
||||
: trimmedParent.endsWith(separator)
|
||||
? `${trimmedParent}${segments.join(separator)}`
|
||||
: `${trimmedParent}${separator}${segments.join(separator)}`
|
||||
}
|
||||
|
||||
function getRelativeSegments(parentPath: string, repoPath: string): string[] {
|
||||
const relativePath = relativePathInsideRoot(parentPath, repoPath)
|
||||
if (relativePath !== null) {
|
||||
return splitPath(relativePath)
|
||||
}
|
||||
const normalizedParent = trimPathSeparators(parentPath)
|
||||
const normalizedRepo = trimPathSeparators(repoPath)
|
||||
const parentWithSeparator = `${normalizedParent}/`
|
||||
const normalizedRepoForMatch = normalizedRepo.replace(/\\/g, '/')
|
||||
const normalizedParentForMatch = normalizedParent.replace(/\\/g, '/')
|
||||
const parentWithMatchSeparator = `${normalizedParentForMatch}/`
|
||||
if (normalizedRepoForMatch.startsWith(parentWithMatchSeparator)) {
|
||||
return splitPath(normalizedRepoForMatch.slice(parentWithMatchSeparator.length))
|
||||
}
|
||||
if (normalizedRepo.startsWith(parentWithSeparator)) {
|
||||
return splitPath(normalizedRepo.slice(parentWithSeparator.length))
|
||||
}
|
||||
return splitPath(normalizedRepo).slice(-1)
|
||||
}
|
||||
|
||||
export function createNestedProjectGroupResolver(args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
mode: ProjectGroupImportMode
|
||||
createGroup: (input: CreateGroupInput) => ProjectGroup
|
||||
}): NestedProjectGroupResolver {
|
||||
const createdGroups: ProjectGroup[] = []
|
||||
const groupsByRelativeDir = new Map<string, ProjectGroup>()
|
||||
|
||||
const ensureGroup = (relativeDirs: readonly string[]): ProjectGroup | undefined => {
|
||||
if (args.mode !== 'group') {
|
||||
return undefined
|
||||
}
|
||||
const key = relativeDirs.join('/')
|
||||
const existing = groupsByRelativeDir.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const parentDirs = relativeDirs.slice(0, -1)
|
||||
const parentGroup = relativeDirs.length > 0 ? ensureGroup(parentDirs) : undefined
|
||||
const group = args.createGroup({
|
||||
name: relativeDirs.length === 0 ? args.groupName : (relativeDirs.at(-1) ?? args.groupName),
|
||||
parentPath: joinPath(args.parentPath, relativeDirs),
|
||||
parentGroupId: parentGroup?.id ?? null,
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
groupsByRelativeDir.set(key, group)
|
||||
createdGroups.push(group)
|
||||
return group
|
||||
}
|
||||
|
||||
return {
|
||||
getGroupForRepo: (repoPath) => {
|
||||
const segments = getRelativeSegments(args.parentPath, repoPath)
|
||||
// Why: direct child repos belong to the selected-folder group; nested repos
|
||||
// belong to the deepest intermediate directory group.
|
||||
return ensureGroup(segments.slice(0, -1))
|
||||
},
|
||||
getRootGroup: () => groupsByRelativeDir.get(''),
|
||||
getCreatedGroups: () => [...createdGroups]
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNestedRepoSelection(args: {
|
||||
scan: NestedRepoScanResult
|
||||
projectPaths: readonly string[]
|
||||
}): ResolvedNestedRepoSelection {
|
||||
const candidatesByPath = new Map(
|
||||
args.scan.repos.map((repo) => [normalizeRuntimePathForComparison(repo.path), repo.path])
|
||||
)
|
||||
const selectedPaths: string[] = []
|
||||
const rejectedPaths: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const repoPath of args.projectPaths) {
|
||||
const normalizedPath = normalizeRuntimePathForComparison(repoPath)
|
||||
if (seen.has(normalizedPath)) {
|
||||
continue
|
||||
}
|
||||
seen.add(normalizedPath)
|
||||
const canonicalPath = candidatesByPath.get(normalizedPath)
|
||||
if (canonicalPath) {
|
||||
selectedPaths.push(canonicalPath)
|
||||
} else {
|
||||
// Why: imports are derived from a bounded scan of this parent folder;
|
||||
// callers must not smuggle unrelated paths into the group hierarchy.
|
||||
rejectedPaths.push(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
return { selectedPaths, rejectedPaths }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -43,11 +43,16 @@ import type {
|
|||
WorktreeStartupLaunch,
|
||||
LinearIssueUpdate,
|
||||
LinearWorkspaceSelection,
|
||||
NestedRepoScanResult,
|
||||
ProjectGroup,
|
||||
ProjectGroupImportMode,
|
||||
ProjectGroupImportResult,
|
||||
TabGroupLayoutNode,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { getNextProjectGroupOrder } from '../../shared/project-groups'
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import { FIRST_PANE_ID } from '../../shared/pane-key'
|
||||
|
|
@ -372,6 +377,16 @@ import type { RateLimitState } from '../../shared/rate-limit-types'
|
|||
import type { VoiceSettings } from '../../shared/speech-types'
|
||||
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
|
||||
import {
|
||||
createNestedProjectGroupResolver,
|
||||
resolveNestedRepoSelection
|
||||
} from '../project-groups/nested-repo-import'
|
||||
|
||||
function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string {
|
||||
console.warn(`[project-groups] ${context}`, error)
|
||||
return 'Repository could not be imported'
|
||||
}
|
||||
|
||||
type RuntimeAccountServices = {
|
||||
claudeAccounts: ClaudeAccountService
|
||||
|
|
@ -399,7 +414,12 @@ type RuntimeStore = {
|
|||
getRepo: Store['getRepo']
|
||||
addRepo: Store['addRepo']
|
||||
updateRepo: Store['updateRepo']
|
||||
removeRepo?: Store['removeRepo']
|
||||
getProjectGroups?: Store['getProjectGroups']
|
||||
createProjectGroup?: Store['createProjectGroup']
|
||||
updateProjectGroup?: Store['updateProjectGroup']
|
||||
deleteProjectGroup?: Store['deleteProjectGroup']
|
||||
moveProjectToGroup?: Store['moveProjectToGroup']
|
||||
removeProject?: Store['removeProject']
|
||||
reorderRepos?: Store['reorderRepos']
|
||||
getAllWorktreeMeta: Store['getAllWorktreeMeta']
|
||||
getWorktreeMeta: Store['getWorktreeMeta']
|
||||
|
|
@ -5071,6 +5091,171 @@ export class OrcaRuntimeService {
|
|||
return this.store?.getRepos() ?? []
|
||||
}
|
||||
|
||||
listProjectGroups(): ProjectGroup[] {
|
||||
return this.store?.getProjectGroups?.() ?? []
|
||||
}
|
||||
|
||||
async createProjectGroup(input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom?: ProjectGroup['createdFrom']
|
||||
}): Promise<ProjectGroup> {
|
||||
if (!this.store?.createProjectGroup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const group = this.store.createProjectGroup({
|
||||
name: input.name,
|
||||
parentPath: input.parentPath ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom ?? 'manual'
|
||||
})
|
||||
this.notifier?.reposChanged()
|
||||
return group
|
||||
}
|
||||
|
||||
async updateProjectGroup(
|
||||
groupId: string,
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
): Promise<ProjectGroup | null> {
|
||||
if (!this.store?.updateProjectGroup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const updated = this.store.updateProjectGroup(groupId, updates)
|
||||
if (updated) {
|
||||
this.notifier?.reposChanged()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
async deleteProjectGroup(groupId: string): Promise<{ deleted: boolean }> {
|
||||
if (!this.store?.deleteProjectGroup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const deleted = this.store.deleteProjectGroup(groupId)
|
||||
if (deleted) {
|
||||
this.notifier?.reposChanged()
|
||||
}
|
||||
return { deleted }
|
||||
}
|
||||
|
||||
async moveProjectToGroup(
|
||||
repoSelector: string,
|
||||
groupId: string | null,
|
||||
order?: number
|
||||
): Promise<Repo> {
|
||||
if (!this.store?.moveProjectToGroup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const moved = this.store.moveProjectToGroup(repo.id, groupId, order)
|
||||
if (!moved) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
this.notifier?.reposChanged()
|
||||
return moved
|
||||
}
|
||||
|
||||
async scanNestedRepos(path: string): Promise<NestedRepoScanResult> {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error('Project path must be an absolute path')
|
||||
}
|
||||
return scanNestedRepos({ path })
|
||||
}
|
||||
|
||||
async importNestedRepos(args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
projectPaths: string[]
|
||||
mode: ProjectGroupImportMode
|
||||
}): Promise<ProjectGroupImportResult> {
|
||||
if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
if (!isAbsolute(args.parentPath)) {
|
||||
throw new Error('Project path must be an absolute path')
|
||||
}
|
||||
const scan = await scanNestedRepos({ path: args.parentPath })
|
||||
const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths })
|
||||
const groupResolver = createNestedProjectGroupResolver({
|
||||
parentPath: scan.selectedPath,
|
||||
groupName: args.groupName,
|
||||
mode: args.mode,
|
||||
createGroup: (input) => this.store!.createProjectGroup!(input)
|
||||
})
|
||||
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
|
||||
(repoPath) => ({
|
||||
path: repoPath,
|
||||
status: 'failed',
|
||||
error: 'Repository was not found in the nested repo scan result'
|
||||
})
|
||||
)
|
||||
for (const repoPath of selection.selectedPaths) {
|
||||
try {
|
||||
if (!isGitRepo(repoPath)) {
|
||||
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
|
||||
continue
|
||||
}
|
||||
const existing = this.store
|
||||
.getRepos()
|
||||
.find((repo) => runtimePathsEqual(repo.path, repoPath))
|
||||
const group = groupResolver.getGroupForRepo(repoPath)
|
||||
if (existing) {
|
||||
if (group) {
|
||||
this.store.moveProjectToGroup(existing.id, group.id)
|
||||
}
|
||||
results.push({ path: repoPath, projectId: existing.id, status: 'already-known' })
|
||||
continue
|
||||
}
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: repoPath,
|
||||
displayName: getRepoName(repoPath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
...(group
|
||||
? {
|
||||
projectGroupId: group.id,
|
||||
projectGroupOrder: getNextProjectGroupOrder(this.store.getRepos(), group.id)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
this.store.addRepo(repo)
|
||||
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
|
||||
} catch (error) {
|
||||
results.push({
|
||||
path: repoPath,
|
||||
status: 'failed',
|
||||
error: sanitizeNestedRepoRuntimeImportError(
|
||||
'Failed to import nested repository in runtime',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
const importedCount = results.filter((entry) => entry.status === 'imported').length
|
||||
const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length
|
||||
const failedCount = results.filter((entry) => entry.status === 'failed').length
|
||||
if (importedCount + alreadyKnownCount === 0) {
|
||||
for (const group of groupResolver.getCreatedGroups().reverse()) {
|
||||
this.store.deleteProjectGroup?.(group.id)
|
||||
}
|
||||
}
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifier?.reposChanged()
|
||||
const rootGroup = groupResolver.getRootGroup()
|
||||
return {
|
||||
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
|
||||
projects: results,
|
||||
importedCount,
|
||||
alreadyKnownCount,
|
||||
failedCount
|
||||
}
|
||||
}
|
||||
|
||||
async listSparsePresets(repoSelector: string) {
|
||||
if (!this.store?.getSparsePresets) {
|
||||
throw new Error('runtime_unavailable')
|
||||
|
|
@ -5110,7 +5295,7 @@ export class OrcaRuntimeService {
|
|||
if (!isAbsolute(path)) {
|
||||
// Why: remote clients may run in a different cwd than the server. Require
|
||||
// server-side repo paths to be explicit so `orca serve` cwd is irrelevant.
|
||||
throw new Error('Repo path must be an absolute path')
|
||||
throw new Error('Project path must be an absolute path')
|
||||
}
|
||||
if (kind === 'git' && !isGitRepo(path)) {
|
||||
throw new Error(`Not a valid git repository: ${path}`)
|
||||
|
|
@ -5367,6 +5552,8 @@ export class OrcaRuntimeService {
|
|||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
|
|
@ -5384,12 +5571,12 @@ export class OrcaRuntimeService {
|
|||
return updated
|
||||
}
|
||||
|
||||
async removeRepo(repoSelector: string): Promise<{ removed: true }> {
|
||||
if (!this.store?.removeRepo) {
|
||||
async removeProject(repoSelector: string): Promise<{ removed: true }> {
|
||||
if (!this.store?.removeProject) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
this.store.removeRepo(repo.id)
|
||||
this.store.removeProject(repo.id)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
invalidateAuthorizedRootsCache()
|
||||
this.notifier?.reposChanged()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -262,16 +262,16 @@ export function buildSkillDiscoverySources(
|
|||
)
|
||||
]
|
||||
|
||||
const repoPaths = new Set<string>()
|
||||
const projectPaths = new Set<string>()
|
||||
for (const repo of args.repos ?? []) {
|
||||
if (repo.connectionId) {
|
||||
continue
|
||||
}
|
||||
repoPaths.add(repo.path)
|
||||
projectPaths.add(repo.path)
|
||||
}
|
||||
repoPaths.add(cwd)
|
||||
projectPaths.add(cwd)
|
||||
|
||||
for (const repoPath of repoPaths) {
|
||||
for (const repoPath of projectPaths) {
|
||||
const label = `Repo ${basename(repoPath)}`
|
||||
roots.push(
|
||||
source(
|
||||
|
|
|
|||
|
|
@ -90,9 +90,13 @@ import type {
|
|||
PRInfo,
|
||||
PRRefreshOutcome,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectGroupImportResult,
|
||||
ProjectGroupImportMode,
|
||||
ShellHydrationFailureReason,
|
||||
SparsePreset,
|
||||
SearchOptions,
|
||||
NestedRepoScanResult,
|
||||
SearchResult,
|
||||
StatsSummary,
|
||||
MemorySnapshot,
|
||||
|
|
@ -629,6 +633,8 @@ export type PreloadApi = {
|
|||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
|
|
@ -661,6 +667,37 @@ export type PreloadApi = {
|
|||
}) => Promise<BaseRefSearchResult[]>
|
||||
onChanged: (callback: () => void) => () => void
|
||||
}
|
||||
projectGroups: {
|
||||
list: () => Promise<ProjectGroup[]>
|
||||
create: (args: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom?: ProjectGroup['createdFrom']
|
||||
}) => Promise<ProjectGroup>
|
||||
update: (args: {
|
||||
groupId: string
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
}) => Promise<ProjectGroup | null>
|
||||
delete: (args: { groupId: string }) => Promise<boolean>
|
||||
moveProject: (args: {
|
||||
projectId: string
|
||||
groupId: string | null
|
||||
order?: number
|
||||
}) => Promise<Repo | null>
|
||||
scanNested: (args: {
|
||||
path: string
|
||||
connectionId?: string
|
||||
options?: Record<string, unknown>
|
||||
}) => Promise<NestedRepoScanResult>
|
||||
importNested: (args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
projectPaths: string[]
|
||||
connectionId?: string
|
||||
mode: ProjectGroupImportMode
|
||||
}) => Promise<ProjectGroupImportResult>
|
||||
}
|
||||
sparsePresets: {
|
||||
list: (args: { repoId: string }) => Promise<SparsePreset[]>
|
||||
save: (args: {
|
||||
|
|
|
|||
|
|
@ -455,6 +455,37 @@ const api = {
|
|||
}
|
||||
},
|
||||
|
||||
projectGroups: {
|
||||
list: (): Promise<unknown[]> => ipcRenderer.invoke('projectGroups:list'),
|
||||
create: (args: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom?: 'manual' | 'folder-scan' | 'migration'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:create', args),
|
||||
update: (args: { groupId: string; updates: Record<string, unknown> }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('projectGroups:update', args),
|
||||
delete: (args: { groupId: string }): Promise<boolean> =>
|
||||
ipcRenderer.invoke('projectGroups:delete', args),
|
||||
moveProject: (args: {
|
||||
projectId: string
|
||||
groupId: string | null
|
||||
order?: number
|
||||
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:moveProject', args),
|
||||
scanNested: (args: {
|
||||
path: string
|
||||
connectionId?: string
|
||||
options?: Record<string, unknown>
|
||||
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:scanNested', args),
|
||||
importNested: (args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
projectPaths: string[]
|
||||
connectionId?: string
|
||||
mode: 'group' | 'separate'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('projectGroups:importNested', args)
|
||||
},
|
||||
|
||||
sparsePresets: {
|
||||
list: (args: { repoId: string }): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('sparsePresets:list', args),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
type BrowserAutomationVisibilityBridge = {
|
||||
acquire: (browserPageId: string) => Promise<string | null>
|
||||
|
|
@ -75,11 +75,8 @@ export function getBrowserAutomationVisiblePageIds(browserPageIds: readonly stri
|
|||
}
|
||||
|
||||
export function useBrowserAutomationVisiblePageIds(browserPageIds: readonly string[]): Set<string> {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
return useMemo(
|
||||
() => getBrowserAutomationVisiblePageIds(browserPageIds),
|
||||
[browserPageIds, snapshot]
|
||||
)
|
||||
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
return getBrowserAutomationVisiblePageIds(browserPageIds)
|
||||
}
|
||||
|
||||
export function acquireBrowserAutomationVisibility(browserPageId: string): string {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export type DashboardWorktreeCard = {
|
|||
agents: DashboardAgentRow[]
|
||||
}
|
||||
|
||||
export type DashboardRepoGroup = {
|
||||
export type DashboardProjectGroup = {
|
||||
repo: Repo
|
||||
worktrees: DashboardWorktreeCard[]
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ function buildDashboardData(
|
|||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>,
|
||||
now: number
|
||||
): DashboardRepoGroup[] {
|
||||
): DashboardProjectGroup[] {
|
||||
// Why: build a tabId -> entries index once per computation instead of
|
||||
// re-scanning every agent status entry inside the per-tab loop. paneKey is
|
||||
// formatted as `${tabId}:${leafId}`; parsePaneKey also drops legacy numeric
|
||||
|
|
@ -150,7 +150,7 @@ function buildDashboardData(
|
|||
return { repo, worktree, agents } satisfies DashboardWorktreeCard
|
||||
})
|
||||
|
||||
return { repo, worktrees } satisfies DashboardRepoGroup
|
||||
return { repo, worktrees } satisfies DashboardProjectGroup
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ function buildDashboardData(
|
|||
* Not used to render anything directly — the inline list reads its own
|
||||
* worktree-scoped slice via useWorktreeAgentRows.
|
||||
*/
|
||||
export function useDashboardData(): DashboardRepoGroup[] {
|
||||
export function useDashboardData(): DashboardProjectGroup[] {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ChevronLeft, CornerDownLeft, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||
|
|
@ -14,6 +13,7 @@ import { RepoStep } from './RepoStep'
|
|||
import { OnboardingTourStep } from './OnboardingTourStep'
|
||||
import { STEPS, useOnboardingFlow } from './use-onboarding-flow'
|
||||
import { OnboardingSkipConfirmationDialog } from './OnboardingSkipConfirmationDialog'
|
||||
import { OnboardingFooter } from './OnboardingFooter'
|
||||
import logo from '../../../../../resources/logo.svg'
|
||||
|
||||
const stepCopy = {
|
||||
|
|
@ -287,7 +287,11 @@ export default function OnboardingFlow({
|
|||
? 'mt-7 overflow-hidden'
|
||||
: cn(
|
||||
'scrollbar-sleek overflow-y-auto pr-1',
|
||||
currentStep.id === 'agentSetup' ? 'mt-4' : 'mt-10'
|
||||
currentStep.id === 'agentSetup'
|
||||
? 'mt-4'
|
||||
: currentStep.id === 'repo'
|
||||
? 'mt-6'
|
||||
: 'mt-10'
|
||||
)
|
||||
)}
|
||||
>
|
||||
|
|
@ -335,6 +339,13 @@ export default function OnboardingFlow({
|
|||
<RepoStep
|
||||
cloneUrl={flow.cloneUrl}
|
||||
onCloneUrlChange={flow.setCloneUrl}
|
||||
nestedScan={flow.nestedScan}
|
||||
nestedSelectedPaths={flow.nestedSelectedPaths}
|
||||
onNestedSelectedPathsChange={flow.setNestedSelectedPaths}
|
||||
nestedGroupName={flow.nestedGroupName}
|
||||
onNestedGroupNameChange={flow.setNestedGroupName}
|
||||
onImportNested={(mode) => void flow.importNested(mode)}
|
||||
onCancelNested={flow.cancelNested}
|
||||
onOpenFolder={() => void flow.openFolder()}
|
||||
onOpenServerFolder={(kind) => void flow.openFolder(kind)}
|
||||
onClone={() => void flow.clone()}
|
||||
|
|
@ -352,56 +363,28 @@ export default function OnboardingFlow({
|
|||
</div>
|
||||
|
||||
{shouldShowFooter && (
|
||||
<footer className="mt-6 flex flex-none items-center justify-between border-t border-border pt-5">
|
||||
{shouldShowSkipToProjectSetup ? (
|
||||
<button
|
||||
className="rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-muted-foreground"
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={() => void flow.skipToRepo()}
|
||||
>
|
||||
Skip to project setup
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{stepIndex > 0 && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-60"
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={flow.back}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{(currentStep.id !== 'repo' || flow.hasExistingProject) && (
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-busy={shouldShowFooterBusy}
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={() => {
|
||||
if (isTourStep) {
|
||||
void flow.skipTourToRepo()
|
||||
return
|
||||
}
|
||||
if (currentStep.id === 'repo') {
|
||||
void flow.continueWithExistingProject()
|
||||
return
|
||||
}
|
||||
void flow.next()
|
||||
}}
|
||||
>
|
||||
{shouldShowFooterBusy ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{footerPrimaryLabel}
|
||||
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
|
||||
<span>{continueShortcutModifierLabel}</span>
|
||||
<CornerDownLeft className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
<OnboardingFooter
|
||||
shouldShowSkipToProjectSetup={shouldShowSkipToProjectSetup}
|
||||
busyLabel={busyLabel}
|
||||
onSkipToRepo={() => void flow.skipToRepo()}
|
||||
stepIndex={stepIndex}
|
||||
onBack={flow.back}
|
||||
showPrimary={currentStep.id !== 'repo' || flow.hasExistingProject}
|
||||
primaryBusy={shouldShowFooterBusy}
|
||||
primaryLabel={footerPrimaryLabel}
|
||||
shortcutModifierLabel={continueShortcutModifierLabel}
|
||||
onPrimary={() => {
|
||||
if (isTourStep) {
|
||||
void flow.skipTourToRepo()
|
||||
return
|
||||
}
|
||||
if (currentStep.id === 'repo') {
|
||||
void flow.continueWithExistingProject()
|
||||
return
|
||||
}
|
||||
void flow.next()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { ChevronLeft, CornerDownLeft, Loader2 } from 'lucide-react'
|
||||
|
||||
type OnboardingFooterProps = {
|
||||
shouldShowSkipToProjectSetup: boolean
|
||||
busyLabel: string | null
|
||||
onSkipToRepo: () => void
|
||||
stepIndex: number
|
||||
onBack: () => void
|
||||
showPrimary: boolean
|
||||
primaryBusy: boolean
|
||||
primaryLabel: string
|
||||
shortcutModifierLabel: string
|
||||
onPrimary: () => void
|
||||
}
|
||||
|
||||
export function OnboardingFooter({
|
||||
shouldShowSkipToProjectSetup,
|
||||
busyLabel,
|
||||
onSkipToRepo,
|
||||
stepIndex,
|
||||
onBack,
|
||||
showPrimary,
|
||||
primaryBusy,
|
||||
primaryLabel,
|
||||
shortcutModifierLabel,
|
||||
onPrimary
|
||||
}: OnboardingFooterProps): React.JSX.Element {
|
||||
return (
|
||||
<footer className="mt-6 flex flex-none items-center justify-between border-t border-border pt-5">
|
||||
{shouldShowSkipToProjectSetup ? (
|
||||
<button
|
||||
className="rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-muted-foreground"
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={onSkipToRepo}
|
||||
>
|
||||
Skip to project setup
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{stepIndex > 0 && (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-60"
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{showPrimary && (
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-busy={primaryBusy}
|
||||
disabled={Boolean(busyLabel)}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{primaryBusy ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{primaryLabel}
|
||||
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-primary-foreground/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
|
||||
<span>{shortcutModifierLabel}</span>
|
||||
<CornerDownLeft className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@ function renderRepoStep(overrides: Partial<ComponentProps<typeof RepoStep>> = {}
|
|||
<RepoStep
|
||||
cloneUrl=""
|
||||
onCloneUrlChange={vi.fn()}
|
||||
nestedScan={null}
|
||||
nestedSelectedPaths={new Set()}
|
||||
onNestedSelectedPathsChange={vi.fn()}
|
||||
nestedGroupName=""
|
||||
onNestedGroupNameChange={vi.fn()}
|
||||
onImportNested={vi.fn()}
|
||||
onCancelNested={vi.fn()}
|
||||
onOpenFolder={vi.fn()}
|
||||
onOpenServerFolder={vi.fn()}
|
||||
onClone={vi.fn()}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,26 @@
|
|||
import { ArrowRight, FolderOpen, GitBranch, Server } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
FolderOpen,
|
||||
FolderTree,
|
||||
GitBranch,
|
||||
Lightbulb,
|
||||
Server
|
||||
} from 'lucide-react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { NestedRepoTreePreview } from '@/components/repo/NestedRepoTreePreview'
|
||||
import type { NestedRepoScanResult } from '../../../../shared/types'
|
||||
|
||||
type RepoStepProps = {
|
||||
cloneUrl: string
|
||||
onCloneUrlChange: (value: string) => void
|
||||
nestedScan: NestedRepoScanResult | null
|
||||
nestedSelectedPaths: Set<string>
|
||||
onNestedSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
|
||||
nestedGroupName: string
|
||||
onNestedGroupNameChange: (value: string) => void
|
||||
onImportNested: (mode: 'group' | 'separate') => void
|
||||
onCancelNested: () => void
|
||||
onOpenFolder: () => void
|
||||
onOpenServerFolder: (kind: 'git' | 'folder') => void
|
||||
onClone: () => void
|
||||
|
|
@ -20,6 +38,13 @@ type RepoStepProps = {
|
|||
export function RepoStep({
|
||||
cloneUrl,
|
||||
onCloneUrlChange,
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
onNestedSelectedPathsChange,
|
||||
nestedGroupName,
|
||||
onNestedGroupNameChange,
|
||||
onImportNested,
|
||||
onCancelNested,
|
||||
onOpenFolder,
|
||||
onOpenServerFolder,
|
||||
onClone,
|
||||
|
|
@ -34,6 +59,91 @@ export function RepoStep({
|
|||
error
|
||||
}: RepoStepProps) {
|
||||
const disabled = Boolean(busyLabel)
|
||||
if (nestedScan) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
|
||||
<FolderTree className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-foreground">Import as project group</div>
|
||||
<div className="mt-0.5 truncate text-[13px] text-muted-foreground">
|
||||
{`Found ${nestedScan.repos.length} git ${
|
||||
nestedScan.repos.length === 1 ? 'repository' : 'repositories'
|
||||
} in this folder.`}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{nestedScan.selectedPath}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Group name</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm text-foreground outline-none transition focus:border-foreground/50 focus:ring-2 focus:ring-foreground/15"
|
||||
value={nestedGroupName}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onNestedGroupNameChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<NestedRepoTreePreview
|
||||
scan={nestedScan}
|
||||
selectedPaths={nestedSelectedPaths}
|
||||
onSelectedPathsChange={onNestedSelectedPathsChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{nestedScan.truncated || nestedScan.timedOut ? (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
Showing partial results from a bounded scan.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-3 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
onClick={onCancelNested}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/60 disabled:opacity-40"
|
||||
disabled={disabled || nestedSelectedPaths.size === 0}
|
||||
onClick={() => onImportNested('separate')}
|
||||
>
|
||||
Import separately
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
|
||||
disabled={disabled || nestedSelectedPaths.size === 0 || !nestedGroupName.trim()}
|
||||
onClick={() => onImportNested('group')}
|
||||
>
|
||||
Import as project group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{busyLabel && (
|
||||
<div className="rounded-lg border border-blue-400/30 bg-blue-400/10 px-4 py-2.5 text-sm text-blue-700 dark:text-blue-200">
|
||||
{busyLabel}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/30 bg-red-400/10 px-4 py-2.5 text-sm text-red-700 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{runtimeActive ? (
|
||||
|
|
@ -84,22 +194,30 @@ export function RepoStep({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-4 rounded-xl border border-border bg-muted/30 p-5 text-left transition hover:border-foreground/40 hover:bg-muted/60 disabled:opacity-60"
|
||||
className="group w-full rounded-xl border border-border bg-muted/30 p-5 text-left transition hover:border-foreground/40 hover:bg-muted/60 disabled:opacity-60"
|
||||
disabled={disabled}
|
||||
onClick={onOpenFolder}
|
||||
>
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
|
||||
<FolderOpen className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-foreground">Open a folder</div>
|
||||
<div className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Choose any local directory, git repo or not.
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
|
||||
<FolderOpen className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-foreground">Open a folder</div>
|
||||
<div className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Choose any local directory, git repo or not.
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition group-hover:border-foreground/40">
|
||||
Browse...
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-[3.75rem] mt-3 flex items-center gap-2 rounded-lg border border-border bg-muted px-3 py-2 text-[12px] text-muted-foreground">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
|
||||
<Lightbulb className="size-3.5" />
|
||||
</span>
|
||||
<span>Want to import many repos at once? Select the parent folder.</span>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition group-hover:border-foreground/40">
|
||||
Browse...
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ import { ONBOARDING_FINAL_STEP } from '../../../../shared/constants'
|
|||
import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { EventProps } from '../../../../shared/telemetry-events'
|
||||
import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types'
|
||||
import type {
|
||||
GlobalSettings,
|
||||
NestedRepoScanResult,
|
||||
OnboardingState,
|
||||
Repo,
|
||||
TuiAgent
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION,
|
||||
ONBOARDING_FEATURE_SETUP_IDS,
|
||||
|
|
@ -42,6 +48,16 @@ type TaskSourcesGithubStatus = TaskSourcesSnapshotProps['github_status']
|
|||
type TaskSourcesLinearStatus = TaskSourcesSnapshotProps['linear_status']
|
||||
type TaskSourcesExitAction = TaskSourcesSnapshotProps['exit_action']
|
||||
|
||||
function defaultProjectGroupNameForPath(path: string): string {
|
||||
return (
|
||||
path
|
||||
.replace(/[\\/]+$/g, '')
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.at(-1) ?? path
|
||||
)
|
||||
}
|
||||
|
||||
function getGitHubTaskSourceStatus(
|
||||
status: ReturnType<typeof useAppStore.getState>['preflightStatus'],
|
||||
loading: boolean
|
||||
|
|
@ -81,6 +97,8 @@ export function useOnboardingFlow(
|
|||
const fetchRepos = useAppStore((s) => s.fetchRepos)
|
||||
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
|
||||
const addRepoPath = useAppStore((s) => s.addRepoPath)
|
||||
const scanNestedRepos = useAppStore((s) => s.scanNestedRepos)
|
||||
const importNestedRepos = useAppStore((s) => s.importNestedRepos)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
|
|
@ -114,6 +132,9 @@ export function useOnboardingFlow(
|
|||
const [cloneUrl, setCloneUrl] = useState('')
|
||||
const [serverPath, setServerPath] = useState('')
|
||||
const [cloneDestination, setCloneDestination] = useState('')
|
||||
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
|
||||
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const [nestedGroupName, setNestedGroupName] = useState('')
|
||||
const [tourStarted, setTourStarted] = useState(false)
|
||||
const [busyLabel, setBusyLabel] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -378,10 +399,10 @@ export function useOnboardingFlow(
|
|||
})
|
||||
|
||||
const completeRepo = useCallback(
|
||||
async (repoId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => {
|
||||
async (projectId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => {
|
||||
await fetchRepos()
|
||||
await fetchWorktrees(repoId)
|
||||
const worktree = useAppStore.getState().worktreesByRepo[repoId]?.[0]
|
||||
await fetchWorktrees(projectId)
|
||||
const worktree = useAppStore.getState().worktreesByRepo[projectId]?.[0]
|
||||
if (worktree) {
|
||||
// Why: onboarding asks for a default agent immediately before this step.
|
||||
// Non-git folders skip the composer, so seed their first terminal here.
|
||||
|
|
@ -413,7 +434,7 @@ export function useOnboardingFlow(
|
|||
})
|
||||
if (isGit) {
|
||||
openModal('project-added', {
|
||||
repoId,
|
||||
projectId,
|
||||
defaultWorktreeName: 'orca-worktree-1',
|
||||
telemetrySource: 'onboarding'
|
||||
})
|
||||
|
|
@ -533,6 +554,12 @@ export function useOnboardingFlow(
|
|||
]
|
||||
)
|
||||
|
||||
const showNestedRepoReview = useCallback((scan: NestedRepoScanResult, selectedPath: string) => {
|
||||
setNestedScan(scan)
|
||||
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(defaultProjectGroupNameForPath(selectedPath))
|
||||
}, [])
|
||||
|
||||
const startFeatureSetup = useCallback(async () => {
|
||||
if (
|
||||
nextInFlightRef.current ||
|
||||
|
|
@ -584,8 +611,16 @@ export function useOnboardingFlow(
|
|||
return
|
||||
}
|
||||
track('onboarding_step4_path_clicked', { path: 'open_folder' })
|
||||
setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…')
|
||||
setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…')
|
||||
try {
|
||||
if (kind === 'git') {
|
||||
const scan = await scanNestedRepos(path)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview(scan, path)
|
||||
return
|
||||
}
|
||||
}
|
||||
setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…')
|
||||
const repo = await addRepoPath(path, kind)
|
||||
if (!repo) {
|
||||
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
|
||||
|
|
@ -610,6 +645,11 @@ export function useOnboardingFlow(
|
|||
try {
|
||||
let result = await window.api.repos.add({ path })
|
||||
if ('error' in result && result.error.includes('Not a valid git repository')) {
|
||||
const scan = await scanNestedRepos(path)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview(scan, path)
|
||||
return
|
||||
}
|
||||
result = await window.api.repos.add({ path, kind: 'folder' })
|
||||
}
|
||||
if ('error' in result) {
|
||||
|
|
@ -623,9 +663,73 @@ export function useOnboardingFlow(
|
|||
setBusyLabel(null)
|
||||
}
|
||||
},
|
||||
[addRepoPath, busyLabel, completeRepo, serverPath, settings?.activeRuntimeEnvironmentId]
|
||||
[
|
||||
addRepoPath,
|
||||
busyLabel,
|
||||
completeRepo,
|
||||
scanNestedRepos,
|
||||
serverPath,
|
||||
showNestedRepoReview,
|
||||
settings?.activeRuntimeEnvironmentId
|
||||
]
|
||||
)
|
||||
|
||||
const importNested = useCallback(
|
||||
async (mode: 'group' | 'separate') => {
|
||||
if (!nestedScan || nestedSelectedPaths.size === 0 || busyLabel !== null) {
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setBusyLabel('Importing repositories…')
|
||||
try {
|
||||
const result = await importNestedRepos({
|
||||
parentPath: nestedScan.selectedPath,
|
||||
groupName: nestedGroupName,
|
||||
projectPaths: [...nestedSelectedPaths],
|
||||
mode
|
||||
})
|
||||
const importedRepoIds =
|
||||
result?.projects
|
||||
.map((entry) => entry.projectId)
|
||||
.filter((projectId): projectId is string => typeof projectId === 'string') ?? []
|
||||
const projectId = importedRepoIds[0]
|
||||
if (!projectId) {
|
||||
throw new Error('No repositories imported')
|
||||
}
|
||||
for (const importedRepoId of importedRepoIds) {
|
||||
await fetchWorktrees(importedRepoId)
|
||||
}
|
||||
await completeRepo(projectId, true, 'open_folder')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
|
||||
} finally {
|
||||
setBusyLabel(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
busyLabel,
|
||||
completeRepo,
|
||||
fetchWorktrees,
|
||||
importNestedRepos,
|
||||
nestedGroupName,
|
||||
nestedScan,
|
||||
nestedSelectedPaths
|
||||
]
|
||||
)
|
||||
|
||||
// Why: lets the user back out of the nested-repo step in onboarding to
|
||||
// re-pick a folder/clone target. Mirrors the dialog's left-aligned Back.
|
||||
const cancelNested = useCallback(() => {
|
||||
if (busyLabel !== null) {
|
||||
return
|
||||
}
|
||||
setNestedScan(null)
|
||||
setNestedSelectedPaths(new Set())
|
||||
setNestedGroupName('')
|
||||
setError(null)
|
||||
}, [busyLabel])
|
||||
|
||||
const clone = useCallback(async () => {
|
||||
// Why: re-entry guard — prevents Enter spamming from triggering duplicate clones.
|
||||
if (busyLabel !== null) {
|
||||
|
|
@ -987,6 +1091,13 @@ export function useOnboardingFlow(
|
|||
hasSelectedFeatureSetup,
|
||||
cloneUrl,
|
||||
setCloneUrl,
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
setNestedSelectedPaths,
|
||||
nestedGroupName,
|
||||
setNestedGroupName,
|
||||
importNested,
|
||||
cancelNested,
|
||||
hasExistingProject,
|
||||
serverPath,
|
||||
setServerPath,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
import { useEffect, useMemo, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
import { FolderTree, GitBranch } from 'lucide-react'
|
||||
import type { NestedRepoCandidate, NestedRepoScanResult } from '../../../../shared/types'
|
||||
|
||||
type TreeFolder = {
|
||||
key: string
|
||||
name: string
|
||||
folders: Map<string, TreeFolder>
|
||||
repos: NestedRepoCandidate[]
|
||||
}
|
||||
|
||||
type TreeRow =
|
||||
| { type: 'folder'; key: string; name: string; depth: number; repoCount: number }
|
||||
| { type: 'repo'; repo: NestedRepoCandidate; pathLabel: string; depth: number }
|
||||
|
||||
function splitPathSegments(path: string): string[] {
|
||||
return path
|
||||
.replace(/[\\/]+$/g, '')
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function relativePathSegments(childPath: string, parentPath: string): string[] {
|
||||
const child = splitPathSegments(childPath)
|
||||
const parent = splitPathSegments(parentPath)
|
||||
let i = 0
|
||||
while (i < parent.length && i < child.length && child[i] === parent[i]) {
|
||||
i++
|
||||
}
|
||||
return child.slice(i)
|
||||
}
|
||||
|
||||
function countFolderRepos(folder: TreeFolder): number {
|
||||
let count = folder.repos.length
|
||||
for (const child of folder.folders.values()) {
|
||||
count += countFolderRepos(child)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function repoCountLabel(count: number): string {
|
||||
return `${count} ${count === 1 ? 'repo' : 'repos'}`
|
||||
}
|
||||
|
||||
function pathSeparatorForDisplay(path: string): string {
|
||||
return path.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
function pathLabelForRepo(repo: NestedRepoCandidate, parentPath: string): string {
|
||||
return relativePathSegments(repo.path, parentPath).join(pathSeparatorForDisplay(repo.path))
|
||||
}
|
||||
|
||||
function appendRepoRow(
|
||||
rows: TreeRow[],
|
||||
repo: NestedRepoCandidate,
|
||||
depth: number,
|
||||
parentPath: string
|
||||
): void {
|
||||
rows.push({
|
||||
type: 'repo',
|
||||
repo,
|
||||
pathLabel: pathLabelForRepo(repo, parentPath),
|
||||
depth
|
||||
})
|
||||
}
|
||||
|
||||
function buildRows(scan: NestedRepoScanResult): TreeRow[] {
|
||||
const root: TreeFolder = { key: '', name: '', folders: new Map(), repos: [] }
|
||||
|
||||
for (const repo of scan.repos) {
|
||||
const segments = relativePathSegments(repo.path, scan.selectedPath)
|
||||
const folderSegments = segments.slice(0, -1)
|
||||
let current = root
|
||||
for (const segment of folderSegments) {
|
||||
const key = current.key ? `${current.key}/${segment}` : segment
|
||||
let child = current.folders.get(segment)
|
||||
if (!child) {
|
||||
child = { key, name: segment, folders: new Map(), repos: [] }
|
||||
current.folders.set(segment, child)
|
||||
}
|
||||
current = child
|
||||
}
|
||||
current.repos.push(repo)
|
||||
}
|
||||
|
||||
const rows: TreeRow[] = []
|
||||
const appendFolder = (folder: TreeFolder, depth: number): void => {
|
||||
rows.push({
|
||||
type: 'folder',
|
||||
key: folder.key,
|
||||
name: folder.name,
|
||||
depth,
|
||||
repoCount: countFolderRepos(folder)
|
||||
})
|
||||
for (const repo of folder.repos) {
|
||||
appendRepoRow(rows, repo, depth + 1, scan.selectedPath)
|
||||
}
|
||||
for (const child of folder.folders.values()) {
|
||||
appendFolder(child, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (const repo of root.repos) {
|
||||
appendRepoRow(rows, repo, 0, scan.selectedPath)
|
||||
}
|
||||
for (const folder of root.folders.values()) {
|
||||
appendFolder(folder, 0)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function NestedRepoSelectAllRow({
|
||||
total,
|
||||
selectedCount,
|
||||
disabled,
|
||||
onToggle
|
||||
}: {
|
||||
total: number
|
||||
selectedCount: number
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const allSelected = total > 0 && selectedCount === total
|
||||
const noneSelected = selectedCount === 0
|
||||
const isMixed = !allSelected && !noneSelected
|
||||
const checkboxRef = useRef<HTMLInputElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = isMixed
|
||||
}
|
||||
}, [isMixed])
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2.5 bg-muted/30 px-3 py-2 text-sm hover:bg-muted/50">
|
||||
<input
|
||||
ref={checkboxRef}
|
||||
type="checkbox"
|
||||
className="size-3.5"
|
||||
checked={allSelected}
|
||||
disabled={disabled}
|
||||
onChange={onToggle}
|
||||
aria-label={allSelected ? 'Deselect all' : 'Select all'}
|
||||
/>
|
||||
<span className="text-[12.5px] font-semibold text-foreground">
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">
|
||||
{selectedCount} of {total} selected
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function NestedRepoTreePreview({
|
||||
scan,
|
||||
selectedPaths,
|
||||
onSelectedPathsChange,
|
||||
disabled = false
|
||||
}: {
|
||||
scan: NestedRepoScanResult
|
||||
selectedPaths: Set<string>
|
||||
onSelectedPathsChange: Dispatch<SetStateAction<Set<string>>>
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const rows = useMemo(() => buildRows(scan), [scan])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border bg-background/60">
|
||||
<NestedRepoSelectAllRow
|
||||
total={scan.repos.length}
|
||||
selectedCount={selectedPaths.size}
|
||||
disabled={disabled}
|
||||
onToggle={() => {
|
||||
onSelectedPathsChange((previous) => {
|
||||
if (previous.size === scan.repos.length) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(scan.repos.map((repo) => repo.path))
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ul className="scrollbar-sleek max-h-64 overflow-y-auto">
|
||||
{rows.map((row) =>
|
||||
row.type === 'folder' ? (
|
||||
<li
|
||||
key={`folder:${row.key}`}
|
||||
className="flex items-center gap-2.5 border-t border-border bg-muted/20 px-3 py-2 text-sm"
|
||||
style={{ paddingLeft: 12 + row.depth * 18 }}
|
||||
>
|
||||
<FolderTree className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border bg-background px-2 py-0.5 text-[11px] leading-none font-medium text-muted-foreground">
|
||||
Project group
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{repoCountLabel(row.repoCount)}
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li key={row.repo.path}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2.5 border-t border-border px-3 py-2 text-sm hover:bg-accent"
|
||||
style={{ paddingLeft: 12 + row.depth * 18 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5"
|
||||
checked={selectedPaths.has(row.repo.path)}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
onSelectedPathsChange((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (event.target.checked) {
|
||||
next.add(row.repo.path)
|
||||
} else {
|
||||
next.delete(row.repo.path)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-[13px] font-medium ${
|
||||
selectedPaths.has(row.repo.path) ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{row.repo.displayName}
|
||||
</span>
|
||||
<span className="ml-auto min-w-0 max-w-[52%] truncate text-right font-mono text-[11px] text-muted-foreground">
|
||||
{row.pathLabel}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ type RepositoryPaneProps = {
|
|||
hooksInspectionReady: boolean
|
||||
mayNeedUpdate: boolean
|
||||
updateRepo: (repoId: string, updates: Partial<Repo>) => void
|
||||
removeRepo: (repoId: string) => void
|
||||
removeProject: (repoId: string) => void
|
||||
}
|
||||
|
||||
export function RepositoryPane({
|
||||
|
|
@ -37,7 +37,7 @@ export function RepositoryPane({
|
|||
hooksInspectionReady,
|
||||
mayNeedUpdate,
|
||||
updateRepo,
|
||||
removeRepo
|
||||
removeProject
|
||||
}: RepositoryPaneProps): React.JSX.Element {
|
||||
const isFolder = isFolderRepo(repo)
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
|
|
@ -45,9 +45,9 @@ export function RepositoryPane({
|
|||
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
|
||||
const [copiedTemplate, setCopiedTemplate] = useState(false)
|
||||
|
||||
const handleRemoveRepo = (repoId: string) => {
|
||||
const handleRemoveProject = (repoId: string) => {
|
||||
if (confirmingRemove === repoId) {
|
||||
removeRepo(repoId)
|
||||
removeProject(repoId)
|
||||
setConfirmingRemove(null)
|
||||
return
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ export function RepositoryPane({
|
|||
<Button
|
||||
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRepo(repo.id)}
|
||||
onClick={() => handleRemoveProject(repo.id)}
|
||||
onBlur={() => setConfirmingRemove(null)}
|
||||
className="gap-2"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ function Settings(): React.JSX.Element {
|
|||
const closeSettingsPage = useAppStore((s) => s.closeSettingsPage)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const updateRepo = useAppStore((s) => s.updateRepo)
|
||||
const removeRepo = useAppStore((s) => s.removeRepo)
|
||||
const removeProject = useAppStore((s) => s.removeProject)
|
||||
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
|
||||
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
|
||||
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
|
||||
|
|
@ -1100,7 +1100,7 @@ function Settings(): React.JSX.Element {
|
|||
hooksInspectionReady={Boolean(repoHooksState)}
|
||||
mayNeedUpdate={repoHooksState?.mayNeedUpdate ?? false}
|
||||
updateRepo={updateRepo}
|
||||
removeRepo={removeRepo}
|
||||
removeProject={removeProject}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind'
|
|||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
|
||||
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'setup'
|
||||
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'
|
||||
type RepoKind = 'git' | 'folder'
|
||||
|
||||
export function useCreateRepo(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable max-lines -- Why: the add-project dialog centralizes step routing, clone/remote/create state, and reset semantics across five steps so the modal flow stays in one place. */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { FolderOpen, ArrowLeft, Globe, Monitor } from 'lucide-react'
|
||||
import { FolderOpen, ArrowLeft, Globe, Monitor, FolderTree, Lightbulb } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { NestedRepoTreePreview } from '@/components/repo/NestedRepoTreePreview'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps'
|
||||
import { CreateStep, useCreateRepo } from './AddRepoCreateStep'
|
||||
|
|
@ -24,7 +25,7 @@ import type {
|
|||
AddRepoExistingWorkspaceSource,
|
||||
AddRepoSetupStepAction
|
||||
} from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
|
||||
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
|
||||
import {
|
||||
buildAddRepoExistingWorkspacesTelemetry,
|
||||
|
|
@ -35,11 +36,22 @@ import {
|
|||
isLegacyRepoForExternalWorktreeVisibility
|
||||
} from '../../../../shared/worktree-ownership'
|
||||
|
||||
function defaultProjectGroupNameForPath(path: string): string {
|
||||
return (
|
||||
path
|
||||
.replace(/[\\/]+$/g, '')
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.at(-1) ?? path
|
||||
)
|
||||
}
|
||||
|
||||
const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
const addRepoPath = useAppStore((s) => s.addRepoPath)
|
||||
const scanNestedRepos = useAppStore((s) => s.scanNestedRepos)
|
||||
const importNestedRepos = useAppStore((s) => s.importNestedRepos)
|
||||
const updateRepo = useAppStore((s) => s.updateRepo)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
|
|
@ -51,7 +63,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
|
||||
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup'>(
|
||||
'add'
|
||||
)
|
||||
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
|
||||
const [existingWorkspaceSource, setExistingWorkspaceSource] =
|
||||
useState<AddRepoExistingWorkspaceSource | null>(null)
|
||||
|
|
@ -65,6 +79,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>(
|
||||
null
|
||||
)
|
||||
const [nestedScan, setNestedScan] = useState<NestedRepoScanResult | null>(null)
|
||||
const [nestedSelectedPaths, setNestedSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const [nestedGroupName, setNestedGroupName] = useState('')
|
||||
const [nestedConnectionId, setNestedConnectionId] = useState<string | null>(null)
|
||||
|
||||
// Why: monotonic ID so stale clone callbacks can detect they were superseded.
|
||||
const cloneGenRef = useRef(0)
|
||||
|
|
@ -85,7 +103,21 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
handleOpenRemoteStep,
|
||||
handleAddRemoteRepo,
|
||||
handleConnectTarget
|
||||
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal, setExistingWorkspaceSource)
|
||||
} = useRemoteRepo(
|
||||
fetchWorktrees,
|
||||
setStep,
|
||||
setAddedRepo,
|
||||
closeModal,
|
||||
setExistingWorkspaceSource,
|
||||
scanNestedRepos,
|
||||
(scan, selectedPath, connectionId) => {
|
||||
setNestedScan(scan)
|
||||
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(defaultProjectGroupNameForPath(scan.selectedPath || selectedPath))
|
||||
setNestedConnectionId(connectionId)
|
||||
setStep('nested')
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
createName,
|
||||
|
|
@ -130,13 +162,13 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
}, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir])
|
||||
|
||||
const isOpen = activeModal === 'add-repo'
|
||||
const repoId = addedRepo?.id ?? ''
|
||||
const projectId = addedRepo?.id ?? ''
|
||||
const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
|
||||
|
||||
const worktrees = useMemo(() => {
|
||||
return worktreesByRepo[repoId] ?? []
|
||||
}, [worktreesByRepo, repoId])
|
||||
const detectedResult = repoId ? detectedWorktreesByRepo[repoId] : undefined
|
||||
return worktreesByRepo[projectId] ?? []
|
||||
}, [worktreesByRepo, projectId])
|
||||
const detectedResult = projectId ? detectedWorktreesByRepo[projectId] : undefined
|
||||
const hiddenWorktreeCount =
|
||||
detectedResult?.authoritative === true
|
||||
? detectedResult.worktrees.filter(
|
||||
|
|
@ -181,6 +213,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
setIsCloning(false)
|
||||
setCloneError(null)
|
||||
setCloneProgress(null)
|
||||
setNestedScan(null)
|
||||
setNestedSelectedPaths(new Set())
|
||||
setNestedGroupName('')
|
||||
setNestedConnectionId(null)
|
||||
resetCreateState()
|
||||
resetRemoteState()
|
||||
}, [resetRemoteState, resetCreateState])
|
||||
|
|
@ -192,12 +228,30 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
}
|
||||
}, [isOpen, resetState])
|
||||
|
||||
const isInputStep = step === 'add' || step === 'clone' || step === 'remote' || step === 'create'
|
||||
const isInputStep =
|
||||
step === 'add' ||
|
||||
step === 'clone' ||
|
||||
step === 'remote' ||
|
||||
step === 'create' ||
|
||||
step === 'nested'
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
setIsAdding(true)
|
||||
try {
|
||||
const repo = await addRepo()
|
||||
const path = await window.api.repos.pickFolder()
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
const scan = await scanNestedRepos(path)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
setNestedScan(scan)
|
||||
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(defaultProjectGroupNameForPath(path))
|
||||
setNestedConnectionId(null)
|
||||
setStep('nested')
|
||||
return
|
||||
}
|
||||
const repo = await addRepoPath(path)
|
||||
if (repo && isGitRepoKind(repo)) {
|
||||
setAddedRepo(repo)
|
||||
setExistingWorkspaceSource('local_folder_picker')
|
||||
|
|
@ -211,7 +265,67 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
} finally {
|
||||
setIsAdding(false)
|
||||
}
|
||||
}, [addRepo, fetchWorktrees, closeModal])
|
||||
}, [addRepoPath, closeModal, fetchWorktrees, scanNestedRepos])
|
||||
|
||||
const handleImportNestedRepos = useCallback(
|
||||
async (mode: 'group' | 'separate') => {
|
||||
if (!nestedScan || nestedSelectedPaths.size === 0) {
|
||||
return
|
||||
}
|
||||
setIsAdding(true)
|
||||
try {
|
||||
const result = await importNestedRepos({
|
||||
parentPath: nestedScan.selectedPath,
|
||||
groupName: nestedGroupName,
|
||||
projectPaths: [...nestedSelectedPaths],
|
||||
...(nestedConnectionId ? { connectionId: nestedConnectionId } : {}),
|
||||
mode
|
||||
})
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
const importedRepoIds = result.projects
|
||||
.map((entry) => entry.projectId)
|
||||
.filter((projectId): projectId is string => typeof projectId === 'string')
|
||||
const firstRepoId = importedRepoIds[0]
|
||||
if (!firstRepoId) {
|
||||
toast.error('No repositories imported')
|
||||
return
|
||||
}
|
||||
for (const projectId of importedRepoIds) {
|
||||
await fetchWorktrees(projectId)
|
||||
}
|
||||
const repo = useAppStore.getState().repos.find((entry) => entry.id === firstRepoId)
|
||||
if (repo) {
|
||||
setAddedRepo(repo)
|
||||
setExistingWorkspaceSource(
|
||||
nestedConnectionId
|
||||
? 'ssh_remote_path'
|
||||
: settings?.activeRuntimeEnvironmentId?.trim()
|
||||
? 'runtime_server_path'
|
||||
: 'local_folder_picker'
|
||||
)
|
||||
setStep('setup')
|
||||
}
|
||||
if (result.failedCount > 0) {
|
||||
toast.warning('Some repositories could not be imported', {
|
||||
description: `${result.failedCount} failed`
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsAdding(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
fetchWorktrees,
|
||||
importNestedRepos,
|
||||
nestedGroupName,
|
||||
nestedScan,
|
||||
nestedSelectedPaths,
|
||||
nestedConnectionId,
|
||||
settings?.activeRuntimeEnvironmentId
|
||||
]
|
||||
)
|
||||
|
||||
const handleAddServerPath = useCallback(
|
||||
async (kind: 'git' | 'folder') => {
|
||||
|
|
@ -221,6 +335,17 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
}
|
||||
setIsAddingServerPath(true)
|
||||
try {
|
||||
if (kind === 'git') {
|
||||
const scan = await scanNestedRepos(path)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
setNestedScan(scan)
|
||||
setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path)))
|
||||
setNestedGroupName(defaultProjectGroupNameForPath(path))
|
||||
setNestedConnectionId(null)
|
||||
setStep('nested')
|
||||
return
|
||||
}
|
||||
}
|
||||
const repo = await addRepoPath(path, kind)
|
||||
if (repo && isGitRepoKind(repo)) {
|
||||
setAddedRepo(repo)
|
||||
|
|
@ -236,7 +361,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
setIsAddingServerPath(false)
|
||||
}
|
||||
},
|
||||
[addRepoPath, closeModal, fetchWorktrees, serverPath]
|
||||
[addRepoPath, closeModal, fetchWorktrees, scanNestedRepos, serverPath]
|
||||
)
|
||||
|
||||
const handlePickDestination = useCallback(async () => {
|
||||
|
|
@ -323,16 +448,16 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
useEffect(() => {
|
||||
if (
|
||||
step !== 'setup' ||
|
||||
!repoId ||
|
||||
!projectId ||
|
||||
!existingWorkspaceTelemetry ||
|
||||
!shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) ||
|
||||
detectedTelemetryTrackedRef.current.has(repoId)
|
||||
detectedTelemetryTrackedRef.current.has(projectId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
detectedTelemetryTrackedRef.current.add(repoId)
|
||||
detectedTelemetryTrackedRef.current.add(projectId)
|
||||
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
|
||||
}, [existingWorkspaceSource, existingWorkspaceTelemetry, repoId, step])
|
||||
}, [existingWorkspaceSource, existingWorkspaceTelemetry, projectId, step])
|
||||
|
||||
const trackSetupAction = useCallback(
|
||||
(action: AddRepoSetupStepAction): void => {
|
||||
|
|
@ -361,13 +486,13 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
closeModal()
|
||||
setTimeout(() => {
|
||||
openModal('new-workspace-composer', {
|
||||
initialRepoId: repoId,
|
||||
initialRepoId: projectId,
|
||||
...(name ? { prefilledName: name } : {}),
|
||||
telemetrySource: 'sidebar'
|
||||
})
|
||||
}, 150)
|
||||
},
|
||||
[closeModal, openModal, repoId, trackSetupAction]
|
||||
[closeModal, openModal, projectId, trackSetupAction]
|
||||
)
|
||||
|
||||
const handleStartPrimaryWorktree = useCallback(() => {
|
||||
|
|
@ -385,12 +510,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
const handleConfigureRepo = useCallback(() => {
|
||||
trackSetupAction('configure')
|
||||
closeModal()
|
||||
openSettingsTarget({ pane: 'repo', repoId })
|
||||
openSettingsTarget({ pane: 'repo', repoId: projectId })
|
||||
openSettingsPage()
|
||||
}, [closeModal, openSettingsTarget, openSettingsPage, repoId, trackSetupAction])
|
||||
}, [closeModal, openSettingsTarget, openSettingsPage, projectId, trackSetupAction])
|
||||
|
||||
const finishImportedRepoWithoutOpening = useCallback(async () => {
|
||||
const importedRepoId = repoId
|
||||
const importedRepoId = projectId
|
||||
closeModal()
|
||||
resetState()
|
||||
if (!importedRepoId) {
|
||||
|
|
@ -400,19 +525,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
await fetchWorktrees(importedRepoId)
|
||||
const state = useAppStore.getState()
|
||||
finalizeImportedRepoAfterSkip(state, importedRepoId)
|
||||
}, [closeModal, fetchWorktrees, repoId, resetState])
|
||||
}, [closeModal, fetchWorktrees, projectId, resetState])
|
||||
|
||||
const handleUseExistingWorktrees = useCallback(async () => {
|
||||
if (!repoId) {
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
trackSetupAction('open_existing')
|
||||
if (!otherWorktreesVisible) {
|
||||
const updated = await updateRepo(repoId, { externalWorktreeVisibility: 'show' })
|
||||
const updated = await updateRepo(projectId, { externalWorktreeVisibility: 'show' })
|
||||
if (updated && addedRepo) {
|
||||
setAddedRepo({ ...addedRepo, externalWorktreeVisibility: 'show' })
|
||||
}
|
||||
await fetchWorktrees(repoId)
|
||||
await fetchWorktrees(projectId)
|
||||
}
|
||||
await finishImportedRepoWithoutOpening()
|
||||
}, [
|
||||
|
|
@ -420,7 +545,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
fetchWorktrees,
|
||||
finishImportedRepoWithoutOpening,
|
||||
otherWorktreesVisible,
|
||||
repoId,
|
||||
projectId,
|
||||
trackSetupAction,
|
||||
updateRepo
|
||||
])
|
||||
|
|
@ -468,6 +593,16 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
Back
|
||||
</button>
|
||||
)}
|
||||
{step === 'nested' && (
|
||||
<button
|
||||
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:cursor-default disabled:opacity-40"
|
||||
disabled={isAdding}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{step === 'setup' && (
|
||||
<button
|
||||
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
|
|
@ -612,6 +747,13 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
|
||||
<Lightbulb className="size-3.5" />
|
||||
</span>
|
||||
<span>Want to import many repos at once? Select the parent folder.</span>
|
||||
</div>
|
||||
|
||||
{/* Secondary link rather than a fourth card — create-from-scratch
|
||||
is a less common path than importing. See orca#763. */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
|
|
@ -669,6 +811,75 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
onPickDestination={handlePickDestination}
|
||||
onClone={handleClone}
|
||||
/>
|
||||
) : step === 'nested' && nestedScan ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import as project group</DialogTitle>
|
||||
<DialogDescription>
|
||||
{`Found ${nestedScan.repos.length} git ${
|
||||
nestedScan.repos.length === 1 ? 'repository' : 'repositories'
|
||||
} in this folder.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<FolderTree className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
Group under {nestedGroupName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{nestedScan.selectedPath}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Group name</label>
|
||||
<Input
|
||||
value={nestedGroupName}
|
||||
onChange={(event) => setNestedGroupName(event.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NestedRepoTreePreview
|
||||
scan={nestedScan}
|
||||
selectedPaths={nestedSelectedPaths}
|
||||
onSelectedPathsChange={setNestedSelectedPaths}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
{nestedScan.truncated || nestedScan.timedOut ? (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Showing partial results from a bounded scan.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleBack} disabled={isAdding} variant="ghost">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void handleImportNestedRepos('separate')}
|
||||
disabled={isAdding || nestedSelectedPaths.size === 0}
|
||||
variant="outline"
|
||||
>
|
||||
Import separately
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleImportNestedRepos('group')}
|
||||
disabled={isAdding || nestedSelectedPaths.size === 0 || !nestedGroupName.trim()}
|
||||
>
|
||||
Import as project group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : step === 'create' ? (
|
||||
<CreateStep
|
||||
createName={createName}
|
||||
|
|
|
|||
|
|
@ -15,17 +15,23 @@ import { Input } from '@/components/ui/input'
|
|||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
import { SshTargetRow } from './SshTargetRow'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { NestedRepoScanResult, Repo } from '../../../../shared/types'
|
||||
import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
// ── Remote project hook ─────────────────────────────────────────────
|
||||
|
||||
export function useRemoteRepo(
|
||||
fetchWorktrees: (repoId: string) => Promise<void>,
|
||||
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'setup') => void,
|
||||
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'nested' | 'setup') => void,
|
||||
setAddedRepo: (repo: Repo | null) => void,
|
||||
closeModal: () => void,
|
||||
setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void
|
||||
setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void,
|
||||
scanNestedRepos?: (path: string, connectionId?: string) => Promise<NestedRepoScanResult | null>,
|
||||
showNestedRepoReview?: (
|
||||
scan: NestedRepoScanResult,
|
||||
selectedPath: string,
|
||||
connectionId: string
|
||||
) => void
|
||||
) {
|
||||
const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([])
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<string | null>(null)
|
||||
|
|
@ -101,12 +107,18 @@ export function useRemoteRepo(
|
|||
return
|
||||
}
|
||||
|
||||
const trimmedRemotePath = remotePath.trim()
|
||||
setIsAddingRemote(true)
|
||||
setRemoteError(null)
|
||||
try {
|
||||
const scan = await scanNestedRepos?.(trimmedRemotePath, selectedTargetId)
|
||||
if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) {
|
||||
showNestedRepoReview?.(scan, trimmedRemotePath, selectedTargetId)
|
||||
return
|
||||
}
|
||||
const result = await window.api.repos.addRemote({
|
||||
connectionId: selectedTargetId,
|
||||
remotePath: remotePath.trim()
|
||||
remotePath: trimmedRemotePath
|
||||
})
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
|
|
@ -139,7 +151,7 @@ export function useRemoteRepo(
|
|||
// silently adding as a folder.
|
||||
closeModal()
|
||||
useAppStore.getState().openModal('confirm-non-git-folder', {
|
||||
folderPath: remotePath.trim(),
|
||||
folderPath: trimmedRemotePath,
|
||||
connectionId: selectedTargetId
|
||||
})
|
||||
return
|
||||
|
|
@ -151,6 +163,8 @@ export function useRemoteRepo(
|
|||
}, [
|
||||
selectedTargetId,
|
||||
remotePath,
|
||||
scanNestedRepos,
|
||||
showNestedRepoReview,
|
||||
fetchWorktrees,
|
||||
setStep,
|
||||
setAddedRepo,
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type ProjectGroupDeleteDialogProps = {
|
||||
open: boolean
|
||||
groupName: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function ProjectGroupDeleteDialog({
|
||||
open,
|
||||
groupName,
|
||||
onOpenChange,
|
||||
onConfirm
|
||||
}: ProjectGroupDeleteDialogProps): React.JSX.Element {
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (deleting) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
setDeleting(false)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project group:', error)
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleting, onConfirm, onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDeleting(false)
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Delete Project Group</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Delete <span className="break-all font-medium text-foreground">{groupName}</span> and
|
||||
ungroup its projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={deleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
type ProjectGroupNameDialogProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
initialName: string
|
||||
confirmLabel: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (name: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function ProjectGroupNameDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
initialName,
|
||||
confirmLabel,
|
||||
onOpenChange,
|
||||
onSubmit
|
||||
}: ProjectGroupNameDialogProps): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputId = useId()
|
||||
const [name, setName] = useState(initialName)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const trimmedName = name.trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [initialName, open])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault()
|
||||
if (!trimmedName || submitting) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(trimmedName)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to save project group name:', error)
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[onOpenChange, onSubmit, submitting, trimmedName]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-sm sm:max-w-sm"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={inputId} className="text-[11px] text-muted-foreground">
|
||||
Group Name
|
||||
</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={!trimmedName || submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
|
|||
[filterRepoIds, setFilterRepoIds]
|
||||
)
|
||||
|
||||
const handleRemoveRepo = useCallback(
|
||||
const handleRemoveProject = useCallback(
|
||||
(repoId: string) => {
|
||||
setFilterRepoIds(filterRepoIds.filter((id) => id !== repoId))
|
||||
},
|
||||
|
|
@ -97,7 +97,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
|
|||
if (lastRepo) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleRemoveRepo(lastRepo.id)
|
||||
handleRemoveProject(lastRepo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -119,7 +119,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
|
|||
},
|
||||
[
|
||||
availableRepos,
|
||||
handleRemoveRepo,
|
||||
handleRemoveProject,
|
||||
handleSelectRepo,
|
||||
highlightedRepoId,
|
||||
matchingAvailableRepos,
|
||||
|
|
@ -145,7 +145,7 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
|
|||
onValueChange={setHighlightedRepoId}
|
||||
className="bg-transparent"
|
||||
>
|
||||
<SelectedProjectPills selectedRepos={selectedRepos} onRemoveRepo={handleRemoveRepo} />
|
||||
<SelectedProjectPills selectedRepos={selectedRepos} onRemoveProject={handleRemoveProject} />
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder={selectedRepos.length > 0 ? 'Add project...' : 'Filter projects...'}
|
||||
|
|
@ -191,10 +191,10 @@ const SidebarRepositoryFilterSection = React.memo(function SidebarRepositoryFilt
|
|||
|
||||
function SelectedProjectPills({
|
||||
selectedRepos,
|
||||
onRemoveRepo
|
||||
onRemoveProject
|
||||
}: {
|
||||
selectedRepos: Repo[]
|
||||
onRemoveRepo: (repoId: string) => void
|
||||
onRemoveProject: (repoId: string) => void
|
||||
}) {
|
||||
if (selectedRepos.length === 0) {
|
||||
return null
|
||||
|
|
@ -221,7 +221,7 @@ function SelectedProjectPills({
|
|||
aria-label={`Remove ${repo.displayName} filter`}
|
||||
className="-mr-1 size-4 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onRemoveRepo(repo.id)}
|
||||
onClick={() => onRemoveProject(repo.id)}
|
||||
>
|
||||
<X className="size-2.5" strokeWidth={2.5} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Copy,
|
||||
Bell,
|
||||
BellOff,
|
||||
CircleX,
|
||||
Moon,
|
||||
Pencil,
|
||||
Pin,
|
||||
|
|
@ -24,7 +25,9 @@ import {
|
|||
Kanban,
|
||||
Trash2,
|
||||
Unlink,
|
||||
Workflow
|
||||
Workflow,
|
||||
FolderInput,
|
||||
FolderPlus
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
|
||||
|
|
@ -39,6 +42,7 @@ import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedSc
|
|||
import { getLineageRenderInfo } from './worktree-list-groups'
|
||||
import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
|
||||
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
|
||||
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
|
|
@ -188,11 +192,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const projectGroups = useAppStore((s) => s.projectGroups)
|
||||
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
|
||||
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
|
||||
const repo = useRepoById(worktree.repoId)
|
||||
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
|
||||
const [contextWorktrees, setContextWorktrees] = useState<readonly Worktree[]>(selectedWorktrees)
|
||||
const [createGroupDialogOpen, setCreateGroupDialogOpen] = useState(false)
|
||||
const isDeleting = deleteState?.isDeleting ?? false
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const repoMap = useRepoMap()
|
||||
|
|
@ -281,6 +289,43 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
updateWorktreeMeta(worktree.id, { isPinned: !worktree.isPinned })
|
||||
}, [worktree.id, worktree.isPinned, updateWorktreeMeta])
|
||||
|
||||
const handleCreateGroupFromRepo = useCallback(() => {
|
||||
if (!repo) {
|
||||
return
|
||||
}
|
||||
setCreateGroupDialogOpen(true)
|
||||
}, [repo])
|
||||
|
||||
const handleSubmitNewProjectGroup = useCallback(
|
||||
async (name: string) => {
|
||||
if (!repo) {
|
||||
return
|
||||
}
|
||||
const group = await createProjectGroup(name)
|
||||
if (group) {
|
||||
await moveProjectToGroup(repo.id, group.id)
|
||||
}
|
||||
},
|
||||
[createProjectGroup, moveProjectToGroup, repo]
|
||||
)
|
||||
|
||||
const handleMoveProjectToGroup = useCallback(
|
||||
(groupId: string) => {
|
||||
if (!repo || repo.projectGroupId === groupId) {
|
||||
return
|
||||
}
|
||||
void moveProjectToGroup(repo.id, groupId)
|
||||
},
|
||||
[moveProjectToGroup, repo]
|
||||
)
|
||||
|
||||
const handleRemoveProjectFromGroup = useCallback(() => {
|
||||
if (!repo) {
|
||||
return
|
||||
}
|
||||
void moveProjectToGroup(repo.id, null)
|
||||
}, [moveProjectToGroup, repo])
|
||||
|
||||
const handleAssignWorkspaceStatus = useCallback(
|
||||
(status: string) => {
|
||||
setMenuOpenState(false)
|
||||
|
|
@ -480,6 +525,40 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
)}
|
||||
{worktree.isUnread ? 'Mark Read' : 'Mark Unread'}
|
||||
</DropdownMenuItem>
|
||||
{repo ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleCreateGroupFromRepo} disabled={isDeleting}>
|
||||
<FolderPlus className="size-3.5" />
|
||||
New group from project
|
||||
</DropdownMenuItem>
|
||||
{projectGroups.length > 0 ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={isDeleting}>
|
||||
<FolderInput className="size-3.5" />
|
||||
Move to group
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{projectGroups.map((group) => (
|
||||
<DropdownMenuItem
|
||||
key={group.id}
|
||||
disabled={repo.projectGroupId === group.id}
|
||||
onSelect={() => handleMoveProjectToGroup(group.id)}
|
||||
>
|
||||
<span className="max-w-48 truncate">{group.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : null}
|
||||
{repo.projectGroupId ? (
|
||||
<DropdownMenuItem onSelect={handleRemoveProjectFromGroup} disabled={isDeleting}>
|
||||
<CircleX className="size-3.5" />
|
||||
Remove from group
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
{(validParentWorktreeId || lineage) && (
|
||||
<>
|
||||
|
|
@ -586,6 +665,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ProjectGroupNameDialog
|
||||
open={createGroupDialogOpen}
|
||||
title="New Project Group"
|
||||
description="Create a group and move this project into it."
|
||||
initialName={repo ? `${repo.displayName} group` : ''}
|
||||
confirmLabel="Create"
|
||||
onOpenChange={setCreateGroupDialogOpen}
|
||||
onSubmit={handleSubmitNewProjectGroup}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
CircleX,
|
||||
Ellipsis,
|
||||
Eye,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
Shapes,
|
||||
SlidersHorizontal,
|
||||
|
|
@ -35,12 +37,16 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
Worktree,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
WorktreeLineage,
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition
|
||||
|
|
@ -56,14 +62,14 @@ import { track } from '@/lib/telemetry'
|
|||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import {
|
||||
type GroupHeaderRow,
|
||||
type RepoGroupOrdering,
|
||||
type ProjectGroupOrdering,
|
||||
type Row,
|
||||
type WorktreeGroupBy,
|
||||
ALL_GROUP_KEY,
|
||||
PINNED_GROUP_KEY,
|
||||
buildRows,
|
||||
getGroupKeyForWorktree,
|
||||
getRepoGroupOrdering,
|
||||
getGroupKeysForWorktree,
|
||||
getProjectGroupOrdering,
|
||||
getLineageGroupKey
|
||||
} from './worktree-list-groups'
|
||||
import {
|
||||
|
|
@ -101,7 +107,7 @@ import {
|
|||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT } from '@/lib/scroll-to-current-workspace-status'
|
||||
import { useRepoHeaderDrag } from './repo-header-drag'
|
||||
import { useRepoHeaderDrag } from './project-header-drag'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import {
|
||||
buildWorktreeDragPreviewOffsets,
|
||||
|
|
@ -119,7 +125,7 @@ import {
|
|||
setSidebarPointerDragDocumentStyles,
|
||||
updateSidebarDragPreviewPosition
|
||||
} from './worktree-sidebar-pointer-drag-dom'
|
||||
import { resolveRepoGroupHeaderColor } from './repo-header-color'
|
||||
import { resolveProjectGroupHeaderColor } from './project-header-color'
|
||||
import {
|
||||
areWorktreeSelectionsEqual,
|
||||
getWorktreeSelectionIntent,
|
||||
|
|
@ -132,6 +138,8 @@ import { getRepoHeaderCreateState } from './repo-header-create-state'
|
|||
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
|
||||
import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets'
|
||||
import { keybindingMatchesAction } from '../../../../shared/keybindings'
|
||||
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
|
||||
import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
effectiveExternalWorktreeVisibility,
|
||||
|
|
@ -140,6 +148,15 @@ import {
|
|||
import { RepoIconGlyph } from '@/components/repo/repo-icon'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
|
||||
type ProjectGroupNameDialogState =
|
||||
| { type: 'create-from-repo'; repo: Repo }
|
||||
| { type: 'rename'; groupId: string; currentName: string }
|
||||
|
||||
type ProjectGroupDeleteDialogState = {
|
||||
groupId: string
|
||||
groupName: string
|
||||
}
|
||||
|
||||
// How long to wait after a sortEpoch bump before actually re-sorting.
|
||||
// Prevents jarring position shifts when background events (AI starting work,
|
||||
// terminal title changes) trigger score recalculations.
|
||||
|
|
@ -277,6 +294,7 @@ const LINEAGE_INDENT = 18
|
|||
// Why: top-level worktrees are children of their project header; indent the
|
||||
// group one step so the status dots nest under the folder icon for hierarchy.
|
||||
const WORKTREE_GROUP_INDENT = 18
|
||||
const PROJECT_GROUP_HEADER_INDENT = 10
|
||||
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
|
||||
|
||||
type VirtualizedWorktreeViewportProps = {
|
||||
|
|
@ -284,13 +302,18 @@ type VirtualizedWorktreeViewportProps = {
|
|||
activeWorktreeId: string | null
|
||||
currentWorktreeId: string | null
|
||||
groupBy: WorktreeGroupBy
|
||||
repoGroupOrdering: RepoGroupOrdering
|
||||
projectGroupOrdering: ProjectGroupOrdering
|
||||
toggleGroup: (key: string) => void
|
||||
collapsedGroups: Set<string>
|
||||
handleCreateForRepo: (repoId: string) => void
|
||||
handleOpenRepoSettings: (repoId: string, sectionId?: string) => void
|
||||
handleOpenWorktreeVisibility: (repoId: string) => void
|
||||
handleRemoveRepo: (repo: Repo) => void
|
||||
handleCreateForRepo: (projectId: string) => void
|
||||
handleOpenRepoSettings: (projectId: string, sectionId?: string) => void
|
||||
handleOpenWorktreeVisibility: (projectId: string) => void
|
||||
handleRemoveProject: (repo: Repo) => void
|
||||
handleCreateGroupFromRepo: (repo: Repo) => void
|
||||
handleMoveProjectToGroup: (repo: Repo, groupId: string) => void
|
||||
handleRemoveProjectFromGroup: (repo: Repo) => void
|
||||
handleRenameProjectGroup: (groupId: string, currentName: string) => void
|
||||
handleDeleteProjectGroup: (groupId: string, groupName: string) => void
|
||||
activeModal: string
|
||||
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
|
||||
clearPendingRevealWorktreeId: () => void
|
||||
|
|
@ -314,6 +337,7 @@ type VirtualizedWorktreeViewportProps = {
|
|||
reorderRepos: (orderedIds: string[]) => void
|
||||
prCache: Record<string, unknown> | null
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
projectGroups?: readonly ProjectGroup[]
|
||||
onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void
|
||||
onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
onPinWorktree: (worktreeId: string) => void
|
||||
|
|
@ -583,13 +607,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
activeWorktreeId,
|
||||
currentWorktreeId,
|
||||
groupBy,
|
||||
repoGroupOrdering,
|
||||
projectGroupOrdering,
|
||||
toggleGroup,
|
||||
collapsedGroups,
|
||||
handleCreateForRepo,
|
||||
handleOpenRepoSettings,
|
||||
handleOpenWorktreeVisibility,
|
||||
handleRemoveRepo,
|
||||
handleRemoveProject,
|
||||
handleCreateGroupFromRepo,
|
||||
handleMoveProjectToGroup,
|
||||
handleRemoveProjectFromGroup,
|
||||
handleRenameProjectGroup,
|
||||
handleDeleteProjectGroup,
|
||||
activeModal,
|
||||
pendingRevealWorktree,
|
||||
clearPendingRevealWorktreeId,
|
||||
|
|
@ -606,6 +635,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
reorderRepos,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
projectGroups = [],
|
||||
onMoveWorktreeToStatus,
|
||||
onMoveWorktreesToStatus,
|
||||
onPinWorktree,
|
||||
|
|
@ -649,7 +679,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
})
|
||||
}, [])
|
||||
const suppressWorktreeClickUntilRef = useRef(0)
|
||||
const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
|
||||
const hasProjectGroups = projectGroups.length > 0
|
||||
const canReorderRepoHeaders =
|
||||
groupBy === 'repo' && projectGroupOrdering === 'manual' && !hasProjectGroups
|
||||
const lastVisibleRefreshKeyRef = useRef('')
|
||||
const reportVisibleGitHubPRRefreshCandidates = useAppStore(
|
||||
(s) => s.reportVisibleGitHubPRRefreshCandidates
|
||||
|
|
@ -979,23 +1011,26 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
|
||||
if (targetWorktree?.isPinned) {
|
||||
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
|
||||
// of their PR-status / repo group. Only uncollapse the Pinned header
|
||||
// of their PR-status / project group. Only uncollapse the Pinned header
|
||||
// itself — expanding the underlying status group would be surprising since
|
||||
// the user intentionally collapsed it.
|
||||
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
|
||||
toggleGroup(PINNED_GROUP_KEY)
|
||||
}
|
||||
} else if (targetWorktree) {
|
||||
const groupKey = getGroupKeyForWorktree(
|
||||
const groupKeys = getGroupKeysForWorktree(
|
||||
groupBy,
|
||||
targetWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
settings
|
||||
settings,
|
||||
projectGroups
|
||||
)
|
||||
if (groupKey && collapsedGroups.has(groupKey)) {
|
||||
toggleGroup(groupKey)
|
||||
for (const groupKey of groupKeys) {
|
||||
if (collapsedGroups.has(groupKey)) {
|
||||
toggleGroup(groupKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1086,6 +1121,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
collapsedGroups,
|
||||
workspaceStatuses,
|
||||
settings,
|
||||
projectGroups,
|
||||
pendingRevealRetryTick,
|
||||
flashRevealedWorktree
|
||||
])
|
||||
|
|
@ -1180,11 +1216,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
new Set<string>(),
|
||||
repoOrder,
|
||||
workspaceStatuses,
|
||||
repoGroupOrdering,
|
||||
projectGroupOrdering,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
true,
|
||||
settings
|
||||
settings,
|
||||
projectGroups
|
||||
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
|
||||
if (worktreeRows.length === 0) {
|
||||
return
|
||||
|
|
@ -1222,7 +1259,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
activeWorktreeId,
|
||||
virtualizer,
|
||||
groupBy,
|
||||
repoGroupOrdering,
|
||||
projectGroupOrdering,
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
|
|
@ -1230,7 +1267,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
workspaceStatuses,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
settings
|
||||
settings,
|
||||
projectGroups
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -1946,17 +1984,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
firstHeaderIndex
|
||||
})
|
||||
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
|
||||
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
|
||||
const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined
|
||||
const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined
|
||||
const isDraggingThis =
|
||||
canReorderRepoHeaders &&
|
||||
repoDrag.state.draggingRepoId !== null &&
|
||||
repoDrag.state.draggingRepoId === repoIdForHeader
|
||||
repoDrag.state.draggingRepoId === projectIdForHeader
|
||||
const headerWorkspaceStatus =
|
||||
groupBy === 'workspace-status'
|
||||
? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses)
|
||||
: null
|
||||
const isPinnedHeader = row.key === PINNED_GROUP_KEY
|
||||
const repoHeaderColor = resolveRepoGroupHeaderColor({
|
||||
const repoHeaderColor = resolveProjectGroupHeaderColor({
|
||||
groupBy,
|
||||
headerKey: row.key,
|
||||
badgeColor: row.repo?.badgeColor
|
||||
|
|
@ -1970,6 +2009,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
: null
|
||||
})
|
||||
: null
|
||||
const projectGroupDepth = row.projectGroupDepth ?? 0
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
|
|
@ -1999,12 +2039,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-repo-header-id={repoIdForHeader}
|
||||
data-repo-header-id={projectIdForHeader}
|
||||
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
|
||||
data-workspace-status={headerWorkspaceStatus ?? undefined}
|
||||
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
|
||||
className={cn(
|
||||
'group flex h-7 w-full items-center gap-1.5 pl-3 pr-1 text-left transition-all',
|
||||
'group flex h-7 w-full items-center gap-1.5 pr-1 text-left transition-all',
|
||||
'cursor-pointer',
|
||||
isDraggingThis &&
|
||||
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
|
||||
|
|
@ -2016,6 +2056,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
'rounded-md bg-sidebar-accent ring-1 ring-sidebar-ring/40',
|
||||
row.repo && 'overflow-hidden'
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: 12 + Math.min(projectGroupDepth, 6) * PROJECT_GROUP_HEADER_INDENT
|
||||
}}
|
||||
onDragOver={
|
||||
isPinnedHeader
|
||||
? handleWorkspacePinDragOver
|
||||
|
|
@ -2046,8 +2089,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
{row.icon ? (
|
||||
<div
|
||||
onPointerDown={
|
||||
canReorderRepoHeaders && isRepoHeader && repoIdForHeader
|
||||
? (e) => repoDrag.onHandlePointerDown(e, repoIdForHeader)
|
||||
canReorderRepoHeaders && isRepoHeader && projectIdForHeader
|
||||
? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
|
|
@ -2088,6 +2131,51 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isProjectGroupHeader && !row.repo && row.projectGroup?.id ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={`Group actions for ${row.label}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={stopRepoHeaderKeyboardToggle}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (row.projectGroup?.id) {
|
||||
handleRenameProjectGroup(row.projectGroup.id, row.label)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rename group
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
if (row.projectGroup?.id) {
|
||||
handleDeleteProjectGroup(row.projectGroup.id, row.label)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete group
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{row.repo && groupBy === 'repo' ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
|
|
@ -2152,12 +2240,57 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
{getWorktreeVisibilityMenuLabel(row.repo)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (row.repo) {
|
||||
handleCreateGroupFromRepo(row.repo)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
New group from project
|
||||
</DropdownMenuItem>
|
||||
{projectGroups.length > 0 ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderInput className="size-3.5" />
|
||||
Move to group
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{projectGroups.map((group) => (
|
||||
<DropdownMenuItem
|
||||
key={group.id}
|
||||
disabled={row.repo?.projectGroupId === group.id}
|
||||
onSelect={() => {
|
||||
if (row.repo) {
|
||||
handleMoveProjectToGroup(row.repo, group.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="max-w-48 truncate">{group.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : null}
|
||||
{row.repo.projectGroupId ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (row.repo) {
|
||||
handleRemoveProjectFromGroup(row.repo)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CircleX className="size-3.5" />
|
||||
Remove from group
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
if (row.repo) {
|
||||
handleRemoveRepo(row.repo)
|
||||
handleRemoveProject(row.repo)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -2861,6 +2994,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
|
||||
// header order from the sorted visible worktree stream instead.
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const projectGroups = useAppStore((s) => s.projectGroups)
|
||||
const repoOrder = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
repos.forEach((r, i) => map.set(r.id, i))
|
||||
|
|
@ -2868,7 +3002,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
}, [repos])
|
||||
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
|
||||
const reorderReposAction = useAppStore((s) => s.reorderRepos)
|
||||
const repoGroupOrdering = getRepoGroupOrdering(groupBy, sortBy)
|
||||
const projectGroupOrdering = getProjectGroupOrdering(groupBy, sortBy)
|
||||
|
||||
// Build flat row list for rendering
|
||||
const rows: Row[] = useMemo(
|
||||
|
|
@ -2881,11 +3015,12 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
collapsedGroups,
|
||||
repoOrder,
|
||||
workspaceStatuses,
|
||||
repoGroupOrdering,
|
||||
projectGroupOrdering,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
true,
|
||||
settings
|
||||
settings,
|
||||
projectGroups
|
||||
),
|
||||
[
|
||||
groupBy,
|
||||
|
|
@ -2895,10 +3030,11 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
collapsedGroups,
|
||||
repoOrder,
|
||||
workspaceStatuses,
|
||||
repoGroupOrdering,
|
||||
projectGroupOrdering,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
settings
|
||||
settings,
|
||||
projectGroups
|
||||
]
|
||||
)
|
||||
// Why: header/mode changes can shift entire groups, so remount the
|
||||
|
|
@ -3020,28 +3156,28 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
}, [renderedWorktreeIds])
|
||||
|
||||
const handleCreateForRepo = useCallback(
|
||||
(repoId: string) => {
|
||||
openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' })
|
||||
(projectId: string) => {
|
||||
openModal('new-workspace-composer', { initialRepoId: projectId, telemetrySource: 'sidebar' })
|
||||
},
|
||||
[openModal]
|
||||
)
|
||||
|
||||
const handleOpenRepoSettings = useCallback(
|
||||
(repoId: string, sectionId?: string) => {
|
||||
openSettingsTarget({ pane: 'repo', repoId, ...(sectionId ? { sectionId } : {}) })
|
||||
(projectId: string, sectionId?: string) => {
|
||||
openSettingsTarget({ pane: 'repo', repoId: projectId, ...(sectionId ? { sectionId } : {}) })
|
||||
openSettingsPage()
|
||||
},
|
||||
[openSettingsPage, openSettingsTarget]
|
||||
)
|
||||
|
||||
const handleOpenWorktreeVisibility = useCallback(
|
||||
(repoId: string) => {
|
||||
openModal('worktree-visibility', { repoId })
|
||||
(projectId: string) => {
|
||||
openModal('worktree-visibility', { repoId: projectId })
|
||||
},
|
||||
[openModal]
|
||||
)
|
||||
|
||||
const handleRemoveRepo = useCallback(
|
||||
const handleRemoveProject = useCallback(
|
||||
(repo: Repo) => {
|
||||
openModal('confirm-remove-folder', {
|
||||
repoId: repo.id,
|
||||
|
|
@ -3051,6 +3187,68 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
[openModal]
|
||||
)
|
||||
|
||||
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
|
||||
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
|
||||
const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)
|
||||
const deleteProjectGroup = useAppStore((s) => s.deleteProjectGroup)
|
||||
const [projectGroupNameDialog, setProjectGroupNameDialog] =
|
||||
useState<ProjectGroupNameDialogState | null>(null)
|
||||
const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] =
|
||||
useState<ProjectGroupDeleteDialogState | null>(null)
|
||||
|
||||
const handleCreateGroupFromRepo = useCallback((repo: Repo) => {
|
||||
setProjectGroupNameDialog({ type: 'create-from-repo', repo })
|
||||
}, [])
|
||||
|
||||
const handleMoveProjectToGroup = useCallback(
|
||||
(repo: Repo, groupId: string) => {
|
||||
if (repo.projectGroupId === groupId) {
|
||||
return
|
||||
}
|
||||
void moveProjectToGroup(repo.id, groupId)
|
||||
},
|
||||
[moveProjectToGroup]
|
||||
)
|
||||
|
||||
const handleRemoveProjectFromGroup = useCallback(
|
||||
(repo: Repo) => {
|
||||
void moveProjectToGroup(repo.id, null)
|
||||
},
|
||||
[moveProjectToGroup]
|
||||
)
|
||||
|
||||
const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => {
|
||||
setProjectGroupNameDialog({ type: 'rename', groupId, currentName })
|
||||
}, [])
|
||||
|
||||
const handleSubmitProjectGroupName = useCallback(
|
||||
async (name: string) => {
|
||||
if (!projectGroupNameDialog) {
|
||||
return
|
||||
}
|
||||
if (projectGroupNameDialog.type === 'create-from-repo') {
|
||||
const group = await createProjectGroup(name)
|
||||
if (group) {
|
||||
await moveProjectToGroup(projectGroupNameDialog.repo.id, group.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
await updateProjectGroup(projectGroupNameDialog.groupId, { name })
|
||||
},
|
||||
[createProjectGroup, moveProjectToGroup, projectGroupNameDialog, updateProjectGroup]
|
||||
)
|
||||
|
||||
const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => {
|
||||
setProjectGroupDeleteDialog({ groupId, groupName })
|
||||
}, [])
|
||||
|
||||
const handleConfirmDeleteProjectGroup = useCallback(async () => {
|
||||
if (!projectGroupDeleteDialog) {
|
||||
return
|
||||
}
|
||||
await deleteProjectGroup(projectGroupDeleteDialog.groupId)
|
||||
}, [deleteProjectGroup, projectGroupDeleteDialog])
|
||||
|
||||
const moveWorktreeToStatus = useCallback(
|
||||
(worktreeId: string, status: WorkspaceStatus) => {
|
||||
const current = worktreeMap.get(worktreeId)
|
||||
|
|
@ -3218,46 +3416,89 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
}
|
||||
|
||||
return (
|
||||
<VirtualizedWorktreeViewport
|
||||
key={viewportResetKey}
|
||||
rows={rows}
|
||||
activeWorktreeId={selectedSidebarWorktreeId}
|
||||
currentWorktreeId={activeWorktreeId}
|
||||
groupBy={groupBy}
|
||||
repoGroupOrdering={repoGroupOrdering}
|
||||
toggleGroup={toggleGroup}
|
||||
collapsedGroups={collapsedGroups}
|
||||
handleCreateForRepo={handleCreateForRepo}
|
||||
handleOpenRepoSettings={handleOpenRepoSettings}
|
||||
handleOpenWorktreeVisibility={handleOpenWorktreeVisibility}
|
||||
handleRemoveRepo={handleRemoveRepo}
|
||||
activeModal={activeModal}
|
||||
pendingRevealWorktree={pendingRevealWorktree}
|
||||
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
|
||||
worktrees={worktrees}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
onContextMenuSelect={selectForContextMenu}
|
||||
repoMap={repoMap}
|
||||
worktreeMap={worktreeMap}
|
||||
worktreeLineageById={worktreeLineageById}
|
||||
repoOrder={repoOrder}
|
||||
allRepoIds={allRepoIds}
|
||||
reorderRepos={(orderedIds) => {
|
||||
void reorderReposAction(orderedIds)
|
||||
}}
|
||||
prCache={prCache}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
onMoveWorktreeToStatus={moveWorktreeToStatus}
|
||||
onMoveWorktreesToStatus={moveWorktreesToStatus}
|
||||
onPinWorktree={pinWorktree}
|
||||
onPinWorktrees={pinWorktrees}
|
||||
onReorderWorktrees={reorderWorktrees}
|
||||
showInlineAgentCards={cardProps.includes('inline-agents')}
|
||||
scrollOffsetRef={scrollOffsetRef}
|
||||
scrollAnchorRef={scrollAnchorRef}
|
||||
/>
|
||||
<>
|
||||
<ProjectGroupNameDialog
|
||||
open={projectGroupNameDialog !== null}
|
||||
title={
|
||||
projectGroupNameDialog?.type === 'rename' ? 'Rename Project Group' : 'New Project Group'
|
||||
}
|
||||
description={
|
||||
projectGroupNameDialog?.type === 'rename'
|
||||
? 'Update the group name shown in the sidebar.'
|
||||
: 'Create a group and move this project into it.'
|
||||
}
|
||||
initialName={
|
||||
projectGroupNameDialog?.type === 'rename'
|
||||
? projectGroupNameDialog.currentName
|
||||
: projectGroupNameDialog
|
||||
? `${projectGroupNameDialog.repo.displayName} group`
|
||||
: ''
|
||||
}
|
||||
confirmLabel={projectGroupNameDialog?.type === 'rename' ? 'Rename' : 'Create'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setProjectGroupNameDialog(null)
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmitProjectGroupName}
|
||||
/>
|
||||
<ProjectGroupDeleteDialog
|
||||
open={projectGroupDeleteDialog !== null}
|
||||
groupName={projectGroupDeleteDialog?.groupName ?? ''}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setProjectGroupDeleteDialog(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteProjectGroup}
|
||||
/>
|
||||
<VirtualizedWorktreeViewport
|
||||
key={viewportResetKey}
|
||||
rows={rows}
|
||||
activeWorktreeId={selectedSidebarWorktreeId}
|
||||
currentWorktreeId={activeWorktreeId}
|
||||
groupBy={groupBy}
|
||||
projectGroupOrdering={projectGroupOrdering}
|
||||
toggleGroup={toggleGroup}
|
||||
collapsedGroups={collapsedGroups}
|
||||
handleCreateForRepo={handleCreateForRepo}
|
||||
handleOpenRepoSettings={handleOpenRepoSettings}
|
||||
handleOpenWorktreeVisibility={handleOpenWorktreeVisibility}
|
||||
handleRemoveProject={handleRemoveProject}
|
||||
handleCreateGroupFromRepo={handleCreateGroupFromRepo}
|
||||
handleMoveProjectToGroup={handleMoveProjectToGroup}
|
||||
handleRemoveProjectFromGroup={handleRemoveProjectFromGroup}
|
||||
handleRenameProjectGroup={handleRenameProjectGroup}
|
||||
handleDeleteProjectGroup={handleDeleteProjectGroup}
|
||||
activeModal={activeModal}
|
||||
pendingRevealWorktree={pendingRevealWorktree}
|
||||
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
|
||||
worktrees={worktrees}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
onContextMenuSelect={selectForContextMenu}
|
||||
repoMap={repoMap}
|
||||
worktreeMap={worktreeMap}
|
||||
worktreeLineageById={worktreeLineageById}
|
||||
repoOrder={repoOrder}
|
||||
allRepoIds={allRepoIds}
|
||||
reorderRepos={(orderedIds) => {
|
||||
void reorderReposAction(orderedIds)
|
||||
}}
|
||||
prCache={prCache}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
projectGroups={projectGroups}
|
||||
onMoveWorktreeToStatus={moveWorktreeToStatus}
|
||||
onMoveWorktreesToStatus={moveWorktreesToStatus}
|
||||
onPinWorktree={pinWorktree}
|
||||
onPinWorktrees={pinWorktrees}
|
||||
onReorderWorktrees={reorderWorktrees}
|
||||
showInlineAgentCards={cardProps.includes('inline-agents')}
|
||||
scrollOffsetRef={scrollOffsetRef}
|
||||
scrollAnchorRef={scrollAnchorRef}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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}')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
|
||||
import { CircleX, Folder, List, Pin } from 'lucide-react'
|
||||
import { CircleX, FolderTree, List, Pin } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type {
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
Worktree,
|
||||
WorktreeLineage,
|
||||
WorkspaceStatusDefinition
|
||||
|
|
@ -23,13 +24,17 @@ import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-stat
|
|||
import type { SortBy } from './smart-sort'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
|
||||
|
||||
export { branchName }
|
||||
|
||||
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
|
||||
export type RepoGroupOrdering = 'manual' | 'visible-worktree-order'
|
||||
export type ProjectGroupOrdering = 'manual' | 'visible-worktree-order'
|
||||
|
||||
export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering {
|
||||
export function getProjectGroupOrdering(
|
||||
groupBy: WorktreeGroupBy,
|
||||
sortBy: SortBy
|
||||
): ProjectGroupOrdering {
|
||||
return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart')
|
||||
? 'visible-worktree-order'
|
||||
: 'manual'
|
||||
|
|
@ -43,6 +48,8 @@ export type GroupHeaderRow = {
|
|||
tone: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
repo?: Repo
|
||||
projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number }
|
||||
projectGroupDepth?: number
|
||||
}
|
||||
|
||||
export type WorktreeRow = {
|
||||
|
|
@ -92,11 +99,15 @@ export const PR_GROUP_META: Record<
|
|||
}
|
||||
}
|
||||
|
||||
export const REPO_GROUP_META = {
|
||||
export const PROJECT_GROUP_META = {
|
||||
tone: 'text-foreground',
|
||||
icon: Folder
|
||||
icon: FolderTree
|
||||
} as const
|
||||
|
||||
export function getProjectGroupHeaderKey(groupId: string | null): string {
|
||||
return groupId ? `project-group:${groupId}` : UNGROUPED_PROJECT_GROUP_KEY
|
||||
}
|
||||
|
||||
export const PINNED_GROUP_KEY = 'pinned'
|
||||
|
||||
export const PINNED_GROUP_META = {
|
||||
|
|
@ -345,13 +356,14 @@ export function buildRows(
|
|||
collapsedGroups: Set<string>,
|
||||
repoOrder?: Map<string, number>,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
repoGroupOrdering: RepoGroupOrdering = 'manual',
|
||||
projectGroupOrdering: ProjectGroupOrdering = 'manual',
|
||||
lineageById: Record<string, WorktreeLineage> = {},
|
||||
worktreeMap: Map<string, Worktree> = new Map(
|
||||
worktrees.map((worktree) => [worktree.id, worktree])
|
||||
),
|
||||
nestLineage = false,
|
||||
settings?: AppState['settings']
|
||||
settings?: AppState['settings'],
|
||||
projectGroups: readonly ProjectGroup[] = []
|
||||
): Row[] {
|
||||
const result: Row[] = []
|
||||
|
||||
|
|
@ -434,7 +446,7 @@ export function buildRows(
|
|||
// visible child. Manual ordering still uses the canonical state.repos
|
||||
// order so repo-header drag has a stable source of truth.
|
||||
const entries = Array.from(grouped.entries())
|
||||
if (repoGroupOrdering === 'manual' && repoOrder) {
|
||||
if (projectGroupOrdering === 'manual' && repoOrder) {
|
||||
const rankFor = (key: string): number => {
|
||||
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
|
||||
const rank = repoOrder.get(repoId)
|
||||
|
|
@ -452,56 +464,168 @@ export function buildRows(
|
|||
orderedGroups.push(...entries)
|
||||
}
|
||||
|
||||
for (const [key, group] of orderedGroups) {
|
||||
const isCollapsed = collapsedGroups.has(key)
|
||||
const repo = group.repo
|
||||
const header =
|
||||
groupBy === 'repo'
|
||||
? {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: group.label,
|
||||
count: group.items.length,
|
||||
tone: REPO_GROUP_META.tone,
|
||||
icon: REPO_GROUP_META.icon,
|
||||
repo
|
||||
}
|
||||
: groupBy === 'workspace-status'
|
||||
? (() => {
|
||||
const workspaceStatus =
|
||||
getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
|
||||
workspaceStatuses[0]?.id ??
|
||||
'in-progress'
|
||||
const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
|
||||
const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
|
||||
return {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: definition?.label ?? workspaceStatus,
|
||||
count: group.items.length,
|
||||
tone: meta.tone,
|
||||
icon: meta.icon
|
||||
}
|
||||
})()
|
||||
: (() => {
|
||||
const prGroup = key.replace(/^pr:/, '') as PRGroupKey
|
||||
const meta = PR_GROUP_META[prGroup]
|
||||
return {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: meta.label,
|
||||
count: group.items.length,
|
||||
tone: meta.tone,
|
||||
icon: meta.icon
|
||||
}
|
||||
})()
|
||||
const appendOrderedGroups = (
|
||||
groupsToAppend: [string, { label: string; items: Worktree[]; repo?: Repo }][],
|
||||
projectGroupDepth = 0
|
||||
): void => {
|
||||
for (const [key, group] of groupsToAppend) {
|
||||
const isCollapsed = collapsedGroups.has(key)
|
||||
const repo = group.repo
|
||||
const header =
|
||||
groupBy === 'repo'
|
||||
? {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: group.label,
|
||||
count: group.items.length,
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
repo,
|
||||
projectGroupDepth
|
||||
}
|
||||
: groupBy === 'workspace-status'
|
||||
? (() => {
|
||||
const workspaceStatus =
|
||||
getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
|
||||
workspaceStatuses[0]?.id ??
|
||||
'in-progress'
|
||||
const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
|
||||
const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
|
||||
return {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: definition?.label ?? workspaceStatus,
|
||||
count: group.items.length,
|
||||
tone: meta.tone,
|
||||
icon: meta.icon
|
||||
}
|
||||
})()
|
||||
: (() => {
|
||||
const prGroup = key.replace(/^pr:/, '') as PRGroupKey
|
||||
const meta = PR_GROUP_META[prGroup]
|
||||
return {
|
||||
type: 'header' as const,
|
||||
key,
|
||||
label: meta.label,
|
||||
count: group.items.length,
|
||||
tone: meta.tone,
|
||||
icon: meta.icon
|
||||
}
|
||||
})()
|
||||
|
||||
result.push(header)
|
||||
if (!isCollapsed) {
|
||||
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
collapsedGroups
|
||||
})
|
||||
result.push(header)
|
||||
if (!isCollapsed) {
|
||||
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
collapsedGroups
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (groupBy !== 'repo' || projectGroups.length === 0) {
|
||||
appendOrderedGroups(orderedGroups)
|
||||
return result
|
||||
}
|
||||
|
||||
const groupByProjectGroupId = new Map<
|
||||
string | null,
|
||||
[string, { label: string; items: Worktree[]; repo?: Repo }][]
|
||||
>()
|
||||
for (const entry of orderedGroups) {
|
||||
const repo = entry[1].repo
|
||||
const projectGroupId = repo?.projectGroupId ?? null
|
||||
const list = groupByProjectGroupId.get(projectGroupId) ?? []
|
||||
list.push(entry)
|
||||
groupByProjectGroupId.set(projectGroupId, list)
|
||||
}
|
||||
|
||||
const sortRepoEntriesWithinGroup = (
|
||||
entries: [string, { label: string; items: Worktree[]; repo?: Repo }][]
|
||||
): [string, { label: string; items: Worktree[]; repo?: Repo }][] => {
|
||||
if (projectGroupOrdering !== 'manual') {
|
||||
return entries
|
||||
}
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftOrder = left[1].repo?.projectGroupOrder
|
||||
const rightOrder = right[1].repo?.projectGroupOrder
|
||||
const leftRank =
|
||||
typeof leftOrder === 'number' && Number.isFinite(leftOrder)
|
||||
? leftOrder
|
||||
: Number.POSITIVE_INFINITY
|
||||
const rightRank =
|
||||
typeof rightOrder === 'number' && Number.isFinite(rightOrder)
|
||||
? rightOrder
|
||||
: Number.POSITIVE_INFINITY
|
||||
return leftRank - rightRank
|
||||
})
|
||||
}
|
||||
|
||||
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
const parentId =
|
||||
group.parentGroupId && projectGroupsById.has(group.parentGroupId) ? group.parentGroupId : null
|
||||
const children = childGroupsByParentId.get(parentId) ?? []
|
||||
children.push(group)
|
||||
childGroupsByParentId.set(parentId, children)
|
||||
}
|
||||
for (const groups of childGroupsByParentId.values()) {
|
||||
groups.sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
const getProjectGroupSubtreeCount = (groupId: string): number => {
|
||||
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const children = childGroupsByParentId.get(groupId) ?? []
|
||||
return children.reduce(
|
||||
(count, child) => count + getProjectGroupSubtreeCount(child.id),
|
||||
directCount
|
||||
)
|
||||
}
|
||||
|
||||
const appendProjectGroup = (projectGroup: ProjectGroup, depth: number): void => {
|
||||
const repoEntries = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(projectGroup.id) ?? [])
|
||||
const childGroups = childGroupsByParentId.get(projectGroup.id) ?? []
|
||||
const key = getProjectGroupHeaderKey(projectGroup.id)
|
||||
result.push({
|
||||
type: 'header',
|
||||
key,
|
||||
label: projectGroup.name,
|
||||
count: getProjectGroupSubtreeCount(projectGroup.id),
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
projectGroup,
|
||||
projectGroupDepth: depth
|
||||
})
|
||||
if (!collapsedGroups.has(key)) {
|
||||
appendOrderedGroups(repoEntries, depth + 1)
|
||||
for (const childGroup of childGroups) {
|
||||
appendProjectGroup(childGroup, depth + 1)
|
||||
}
|
||||
}
|
||||
groupByProjectGroupId.delete(projectGroup.id)
|
||||
}
|
||||
|
||||
for (const projectGroup of childGroupsByParentId.get(null) ?? []) {
|
||||
appendProjectGroup(projectGroup, 0)
|
||||
}
|
||||
|
||||
const ungrouped = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(null) ?? [])
|
||||
if (ungrouped.length > 0) {
|
||||
const key = getProjectGroupHeaderKey(null)
|
||||
result.push({
|
||||
type: 'header',
|
||||
key,
|
||||
label: 'Ungrouped',
|
||||
count: ungrouped.length,
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
projectGroup: { id: null, name: 'Ungrouped', tabOrder: Number.MAX_SAFE_INTEGER }
|
||||
})
|
||||
if (!collapsedGroups.has(key)) {
|
||||
appendOrderedGroups(ungrouped, 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -527,3 +651,45 @@ export function getGroupKeyForWorktree(
|
|||
}
|
||||
return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}`
|
||||
}
|
||||
|
||||
export function getGroupKeysForWorktree(
|
||||
groupBy: WorktreeGroupBy,
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
settings?: AppState['settings'],
|
||||
projectGroups: readonly ProjectGroup[] = []
|
||||
): string[] {
|
||||
const groupKey = getGroupKeyForWorktree(
|
||||
groupBy,
|
||||
worktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
settings
|
||||
)
|
||||
if (!groupKey) {
|
||||
return []
|
||||
}
|
||||
if (groupBy !== 'repo') {
|
||||
return [groupKey]
|
||||
}
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const groupIds: string[] = []
|
||||
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const visited = new Set<string>()
|
||||
let currentGroupId = repo?.projectGroupId ?? null
|
||||
while (currentGroupId && !visited.has(currentGroupId)) {
|
||||
visited.add(currentGroupId)
|
||||
groupIds.unshift(currentGroupId)
|
||||
const parentId = groupsById.get(currentGroupId)?.parentGroupId ?? null
|
||||
currentGroupId = parentId && groupsById.has(parentId) ? parentId : null
|
||||
}
|
||||
return [
|
||||
...(groupIds.length > 0
|
||||
? groupIds.map((id) => getProjectGroupHeaderKey(id))
|
||||
: [getProjectGroupHeaderKey(null)]),
|
||||
groupKey
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import {
|
|||
UNATTRIBUTED_REPO_ID,
|
||||
type DaemonSession,
|
||||
type Metric,
|
||||
type UnifiedRepoGroup,
|
||||
type UnifiedProjectGroup,
|
||||
type UnifiedSessionRow,
|
||||
type UnifiedWorktreeRow
|
||||
} from './mergeSnapshotAndSessions'
|
||||
|
|
@ -298,7 +298,7 @@ function sortWorktrees(list: UnifiedWorktreeRow[], sort: SortOption): UnifiedWor
|
|||
return copy
|
||||
}
|
||||
|
||||
function sortRepoGroups(groups: UnifiedRepoGroup[], sort: SortOption): UnifiedRepoGroup[] {
|
||||
function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): UnifiedProjectGroup[] {
|
||||
const copy = [...groups]
|
||||
if (sort === 'memory') {
|
||||
copy.sort((a, b) => compareMetricDesc(a.memory, b.memory))
|
||||
|
|
@ -543,7 +543,7 @@ function ResourceTree({
|
|||
onDelete,
|
||||
onKillSession
|
||||
}: {
|
||||
repos: UnifiedRepoGroup[]
|
||||
repos: UnifiedProjectGroup[]
|
||||
sortOption: SortOption
|
||||
collapsedRepos: Set<string>
|
||||
toggleRepo: (repoId: string) => void
|
||||
|
|
@ -558,7 +558,7 @@ function ResourceTree({
|
|||
const worktreeById = useWorktreeMap()
|
||||
|
||||
const sortedRepos = useMemo(() => {
|
||||
const grouped = sortRepoGroups(repos, sortOption)
|
||||
const grouped = sortProjectGroups(repos, sortOption)
|
||||
return grouped.map((repo) => ({
|
||||
...repo,
|
||||
worktrees: sortWorktrees(repo.worktrees, sortOption)
|
||||
|
|
|
|||
|
|
@ -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']])
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export type UnifiedWorktreeRow = {
|
|||
sessions: UnifiedSessionRow[]
|
||||
}
|
||||
|
||||
export type UnifiedRepoGroup = {
|
||||
export type UnifiedProjectGroup = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: Metric
|
||||
|
|
@ -236,8 +236,8 @@ export function mergeSnapshotAndSessions(
|
|||
snapshot: MemorySnapshot | null,
|
||||
daemonSessions: readonly DaemonSession[],
|
||||
ctx: MergeContext
|
||||
): UnifiedRepoGroup[] {
|
||||
const repos = new Map<string, UnifiedRepoGroup>()
|
||||
): UnifiedProjectGroup[] {
|
||||
const repos = new Map<string, UnifiedProjectGroup>()
|
||||
const seenSessionIds = new Set<string>()
|
||||
const index = buildMergeIndex(ctx)
|
||||
// Why: bound = the daemon session id appears as a pty id under some tab.
|
||||
|
|
@ -260,12 +260,12 @@ export function mergeSnapshotAndSessions(
|
|||
repoId: string,
|
||||
repoName: string,
|
||||
initiallyHasRemoteChildren = false
|
||||
): UnifiedRepoGroup {
|
||||
): UnifiedProjectGroup {
|
||||
const existing = repos.get(repoId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const next: UnifiedRepoGroup = {
|
||||
const next: UnifiedProjectGroup = {
|
||||
repoId,
|
||||
repoName,
|
||||
cpu: null,
|
||||
|
|
@ -278,7 +278,7 @@ export function mergeSnapshotAndSessions(
|
|||
}
|
||||
|
||||
function findWorktreeRow(
|
||||
repo: UnifiedRepoGroup,
|
||||
repo: UnifiedProjectGroup,
|
||||
worktreeId: string
|
||||
): UnifiedWorktreeRow | undefined {
|
||||
return repo.worktrees.find((w) => w.worktreeId === worktreeId)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1724,7 +1724,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => {
|
||||
const state = get()
|
||||
let failedCount = 0
|
||||
const perRepoResults = await Promise.all(
|
||||
const perProjectResults = await Promise.all(
|
||||
repos.map(async (r) => {
|
||||
try {
|
||||
return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, options)
|
||||
|
|
@ -1750,13 +1750,13 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
}
|
||||
})
|
||||
)
|
||||
const merged = sortWorkItemsByUpdatedAt(perRepoResults.flat()).slice(0, displayLimit)
|
||||
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
|
||||
return { items: merged, failedCount }
|
||||
},
|
||||
|
||||
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => {
|
||||
let failedCount = 0
|
||||
const perRepoResults = await Promise.all(
|
||||
const perProjectResults = await Promise.all(
|
||||
repos.map(async (r) => {
|
||||
await acquireWorkItemSlot()
|
||||
try {
|
||||
|
|
@ -1793,7 +1793,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
|||
}
|
||||
})
|
||||
)
|
||||
const merged = sortWorkItemsByUpdatedAt(perRepoResults.flat()).slice(0, displayLimit)
|
||||
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
|
||||
return { items: merged, failedCount }
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ auditing and preserving. */
|
|||
import type { StateCreator } from 'zustand'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppState } from '../types'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type {
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectGroupImportResult,
|
||||
NestedRepoScanResult
|
||||
} from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
|
||||
import { getRepoIdFromWorktreeId } from './worktree-helpers'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
|
||||
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
|
||||
|
|
@ -27,6 +33,8 @@ type RepoUpdate = Partial<
|
|||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
| 'sourceControlAi'
|
||||
>
|
||||
>
|
||||
|
|
@ -55,12 +63,12 @@ function getRepoUpdateChains(get: () => AppState): Map<string, Promise<boolean>>
|
|||
return chains
|
||||
}
|
||||
|
||||
function getKnownRepoWorktreeIds(state: AppState, repoId: string): string[] {
|
||||
function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const worktree of state.worktreesByRepo[repoId] ?? []) {
|
||||
for (const worktree of state.worktreesByRepo[projectId] ?? []) {
|
||||
ids.add(worktree.id)
|
||||
}
|
||||
for (const worktree of state.detectedWorktreesByRepo[repoId]?.worktrees ?? []) {
|
||||
for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) {
|
||||
ids.add(worktree.id)
|
||||
}
|
||||
return [...ids]
|
||||
|
|
@ -68,19 +76,41 @@ function getKnownRepoWorktreeIds(state: AppState, repoId: string): string[] {
|
|||
|
||||
export type RepoSlice = {
|
||||
repos: Repo[]
|
||||
projectGroups: ProjectGroup[]
|
||||
activeRepoId: string | null
|
||||
fetchRepos: () => Promise<void>
|
||||
fetchProjectGroups: () => Promise<void>
|
||||
addRepo: () => Promise<Repo | null>
|
||||
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
|
||||
addNonGitFolder: (path: string) => Promise<Repo | null>
|
||||
removeRepo: (repoId: string) => Promise<void>
|
||||
updateRepo: (repoId: string, updates: RepoUpdate) => Promise<boolean>
|
||||
setActiveRepo: (repoId: string | null) => void
|
||||
scanNestedRepos: (path: string, connectionId?: string) => Promise<NestedRepoScanResult | null>
|
||||
importNestedRepos: (args: {
|
||||
parentPath: string
|
||||
groupName: string
|
||||
projectPaths: string[]
|
||||
connectionId?: string
|
||||
mode: 'group' | 'separate'
|
||||
}) => Promise<ProjectGroupImportResult | null>
|
||||
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
|
||||
updateProjectGroup: (
|
||||
groupId: string,
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
) => Promise<boolean>
|
||||
deleteProjectGroup: (groupId: string) => Promise<boolean>
|
||||
moveProjectToGroup: (
|
||||
projectId: string,
|
||||
groupId: string | null,
|
||||
order?: number
|
||||
) => Promise<boolean>
|
||||
removeProject: (projectId: string) => Promise<void>
|
||||
updateRepo: (projectId: string, updates: RepoUpdate) => Promise<boolean>
|
||||
setActiveRepo: (projectId: string | null) => void
|
||||
reorderRepos: (orderedIds: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
activeRepoId: null,
|
||||
|
||||
fetchRepos: async () => {
|
||||
|
|
@ -105,9 +135,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
return {
|
||||
repos,
|
||||
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
|
||||
filterRepoIds: s.filterRepoIds.filter((repoId) => validRepoIds.has(repoId)),
|
||||
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds.filter((repoId) =>
|
||||
validRepoIds.has(repoId)
|
||||
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
|
||||
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds.filter(
|
||||
(projectId) => validRepoIds.has(projectId)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -116,6 +146,193 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
fetchProjectGroups: async () => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const projectGroups =
|
||||
target.kind === 'local'
|
||||
? ((await window.api.projectGroups.list()) as ProjectGroup[])
|
||||
: (
|
||||
await callRuntimeRpc<{ groups: ProjectGroup[] }>(
|
||||
target,
|
||||
'projectGroup.list',
|
||||
undefined,
|
||||
{
|
||||
timeoutMs: 15_000
|
||||
}
|
||||
)
|
||||
).groups
|
||||
set({ projectGroups })
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project groups:', err)
|
||||
}
|
||||
},
|
||||
|
||||
scanNestedRepos: async (path, connectionId) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
return target.kind === 'local'
|
||||
? ((await window.api.projectGroups.scanNested({
|
||||
path,
|
||||
connectionId
|
||||
})) as NestedRepoScanResult)
|
||||
: await callRuntimeRpc<NestedRepoScanResult>(
|
||||
target,
|
||||
'projectGroup.scanNested',
|
||||
{ path },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to scan nested repos:', err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
importNestedRepos: async (args) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const result =
|
||||
target.kind === 'local'
|
||||
? ((await window.api.projectGroups.importNested(args)) as ProjectGroupImportResult)
|
||||
: await callRuntimeRpc<ProjectGroupImportResult>(
|
||||
target,
|
||||
'projectGroup.importNested',
|
||||
{
|
||||
parentPath: args.parentPath,
|
||||
groupName: args.groupName,
|
||||
projectPaths: args.projectPaths,
|
||||
mode: args.mode
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
await get().fetchProjectGroups()
|
||||
await get().fetchRepos()
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('Failed to import nested repos:', err)
|
||||
toast.error('Failed to import repositories', {
|
||||
description: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
createProjectGroup: async (name) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const group =
|
||||
target.kind === 'local'
|
||||
? ((await window.api.projectGroups.create({
|
||||
name,
|
||||
createdFrom: 'manual'
|
||||
})) as ProjectGroup)
|
||||
: (
|
||||
await callRuntimeRpc<{ group: ProjectGroup }>(
|
||||
target,
|
||||
'projectGroup.create',
|
||||
{ name, createdFrom: 'manual' },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).group
|
||||
set((s) => ({ projectGroups: [...s.projectGroups, group] }))
|
||||
return group
|
||||
} catch (err) {
|
||||
console.error('Failed to create project group:', err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectGroup: async (groupId, updates) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const updated =
|
||||
target.kind === 'local'
|
||||
? ((await window.api.projectGroups.update({ groupId, updates })) as ProjectGroup | null)
|
||||
: (
|
||||
await callRuntimeRpc<{ group: ProjectGroup | null }>(
|
||||
target,
|
||||
'projectGroup.update',
|
||||
{ groupId, updates },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).group
|
||||
if (!updated) {
|
||||
return false
|
||||
}
|
||||
set((s) => ({
|
||||
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group))
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to update project group:', err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
deleteProjectGroup: async (groupId) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const deleted =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.delete({ groupId })
|
||||
: (
|
||||
await callRuntimeRpc<{ deleted: boolean }>(
|
||||
target,
|
||||
'projectGroup.delete',
|
||||
{ groupId },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).deleted
|
||||
if (!deleted) {
|
||||
return false
|
||||
}
|
||||
set((s) => {
|
||||
const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId)
|
||||
return {
|
||||
projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)),
|
||||
repos: s.repos.map((repo) =>
|
||||
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project group:', err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
moveProjectToGroup: async (projectId, groupId, order) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const moved =
|
||||
target.kind === 'local'
|
||||
? ((await window.api.projectGroups.moveProject({
|
||||
projectId,
|
||||
groupId,
|
||||
order
|
||||
})) as Repo | null)
|
||||
: (
|
||||
await callRuntimeRpc<{ repo: Repo | null }>(
|
||||
target,
|
||||
'projectGroup.moveProject',
|
||||
{ repo: projectId, groupId, order },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).repo
|
||||
if (!moved) {
|
||||
return false
|
||||
}
|
||||
set((s) => ({ repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)) }))
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to move repo to group:', err)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
addRepoPath: async (path, kind = 'git') => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
|
|
@ -235,21 +452,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
removeRepo: async (repoId) => {
|
||||
removeProject: async (projectId) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
await (target.kind === 'local'
|
||||
? window.api.repos.remove({ repoId })
|
||||
: callRuntimeRpc(target, 'repo.rm', { repo: repoId }, { timeoutMs: 15_000 }))
|
||||
? window.api.repos.remove({ repoId: projectId })
|
||||
: callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 }))
|
||||
|
||||
get().clearOrcaHookTrustForRepo(repoId)
|
||||
const repoPath = get().repos.find((repo) => repo.id === repoId)?.path
|
||||
get().evictGitHubRepoCaches(repoId, repoPath)
|
||||
get().clearOrcaHookTrustForRepo(projectId)
|
||||
const repoPath = get().repos.find((repo) => repo.id === projectId)?.path
|
||||
get().evictGitHubRepoCaches(projectId, repoPath)
|
||||
const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index')
|
||||
clearRepoSlugCacheEntry(repoId)
|
||||
clearRepoSlugCacheEntry(projectId)
|
||||
|
||||
// Kill PTYs for all worktrees belonging to this repo
|
||||
const worktreeIds = getKnownRepoWorktreeIds(get(), repoId)
|
||||
const worktreeIds = getKnownRepoWorktreeIds(get(), projectId)
|
||||
const killedTabIds = new Set<string>()
|
||||
const killedPtyIds = new Set<string>()
|
||||
if (target.kind === 'environment') {
|
||||
|
|
@ -274,9 +491,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
|
||||
set((s) => {
|
||||
const nextWorktrees = { ...s.worktreesByRepo }
|
||||
delete nextWorktrees[repoId]
|
||||
delete nextWorktrees[projectId]
|
||||
const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo }
|
||||
delete nextDetectedWorktrees[repoId]
|
||||
delete nextDetectedWorktrees[projectId]
|
||||
const nextTabs = { ...s.tabsByWorktree }
|
||||
const nextLayouts = { ...s.terminalLayoutsByTabId }
|
||||
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
|
||||
|
|
@ -314,18 +531,18 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
// forever after the repo is removed.
|
||||
let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId
|
||||
for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) {
|
||||
if (getRepoIdFromWorktreeId(id) === repoId) {
|
||||
if (getRepoIdFromWorktreeId(id) === projectId) {
|
||||
if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) {
|
||||
nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId }
|
||||
}
|
||||
delete nextLastVisitedAtByWorktreeId[id]
|
||||
}
|
||||
}
|
||||
const nextRepos = s.repos.filter((r) => r.id !== repoId)
|
||||
const nextRepos = s.repos.filter((r) => r.id !== projectId)
|
||||
return {
|
||||
repos: nextRepos,
|
||||
activeRepoId: s.activeRepoId === repoId ? null : s.activeRepoId,
|
||||
filterRepoIds: s.filterRepoIds.filter((id) => id !== repoId),
|
||||
activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId,
|
||||
filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId),
|
||||
worktreesByRepo: nextWorktrees,
|
||||
detectedWorktreesByRepo: nextDetectedWorktrees,
|
||||
tabsByWorktree: nextTabs,
|
||||
|
|
@ -359,22 +576,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
updateRepo: async (repoId, updates) => {
|
||||
updateRepo: async (projectId, updates) => {
|
||||
const updateRepoChains = getRepoUpdateChains(get)
|
||||
const applyRepoUpdate = async () => {
|
||||
try {
|
||||
const sanitizedUpdates = sanitizeRepoUpdate(updates)
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
await (target.kind === 'local'
|
||||
? window.api.repos.update({ repoId, updates: sanitizedUpdates })
|
||||
? window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates })
|
||||
: callRuntimeRpc(
|
||||
target,
|
||||
'repo.update',
|
||||
{ repo: repoId, updates: sanitizedUpdates },
|
||||
{ repo: projectId, updates: sanitizedUpdates },
|
||||
{ timeoutMs: 15_000 }
|
||||
))
|
||||
set((s) => ({
|
||||
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...sanitizedUpdates } : r))
|
||||
repos: s.repos.map((r) => (r.id === projectId ? { ...r, ...sanitizedUpdates } : r))
|
||||
}))
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
@ -382,23 +599,23 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
return false
|
||||
}
|
||||
}
|
||||
const previous = updateRepoChains.get(repoId)
|
||||
const previous = updateRepoChains.get(projectId)
|
||||
// Why: repo settings are persisted as full nested values. Preserve call
|
||||
// order per repo so a slower IPC/RPC response cannot overwrite newer state.
|
||||
const next = previous
|
||||
? previous.catch(() => undefined).then(applyRepoUpdate)
|
||||
: applyRepoUpdate()
|
||||
updateRepoChains.set(repoId, next)
|
||||
updateRepoChains.set(projectId, next)
|
||||
const cleanup = () => {
|
||||
if (updateRepoChains.get(repoId) === next) {
|
||||
updateRepoChains.delete(repoId)
|
||||
if (updateRepoChains.get(projectId) === next) {
|
||||
updateRepoChains.delete(projectId)
|
||||
}
|
||||
}
|
||||
void next.then(cleanup, cleanup)
|
||||
return next
|
||||
},
|
||||
|
||||
setActiveRepo: (repoId) => set({ activeRepoId: repoId }),
|
||||
setActiveRepo: (projectId) => set({ activeRepoId: projectId }),
|
||||
|
||||
reorderRepos: async (orderedIds) => {
|
||||
// Optimistically apply the new order so the sidebar updates instantly;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function createOpenInApplicationId(): string {
|
|||
function runtimeScopedStateReset(): Partial<AppState> {
|
||||
return {
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
activeRepoId: null,
|
||||
sparsePresetsByRepo: {},
|
||||
sparsePresetsLoadingByRepo: {},
|
||||
|
|
@ -309,6 +310,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
|
|||
// terminal, browser, and issue IDs cannot be used against the new server
|
||||
// while the new environment is loading.
|
||||
await get().fetchRepos()
|
||||
await get().fetchProjectGroups()
|
||||
await get().fetchAllWorktrees()
|
||||
await get().fetchWorktreeLineage()
|
||||
await get().fetchBrowserSessionProfiles()
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
|||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
repos: [],
|
||||
projectGroups: [],
|
||||
sparsePresetsByRepo: {},
|
||||
worktreeMeta: {},
|
||||
worktreeLineageById: {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearMissingProjectGroupMemberships,
|
||||
createProjectGroup,
|
||||
getNextProjectGroupOrder,
|
||||
getProjectGroupSubtreeIds,
|
||||
normalizeProjectGroupName,
|
||||
normalizeProjectGroups
|
||||
} from './project-groups'
|
||||
import type { Repo } from './types'
|
||||
|
||||
function repo(overrides: Partial<Repo>): Repo {
|
||||
return {
|
||||
id: overrides.id ?? 'repo-1',
|
||||
path: overrides.path ?? '/repo',
|
||||
displayName: overrides.displayName ?? 'repo',
|
||||
badgeColor: '#999',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('project-groups', () => {
|
||||
it('creates a durable project group with normalized defaults', () => {
|
||||
const group = createProjectGroup({
|
||||
name: ' Platform ',
|
||||
parentPath: '/srv/platform',
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 3,
|
||||
now: 100
|
||||
})
|
||||
|
||||
expect(group).toMatchObject({
|
||||
name: 'Platform',
|
||||
parentPath: '/srv/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 3,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 100,
|
||||
updatedAt: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('trims empty group names to a fallback', () => {
|
||||
expect(normalizeProjectGroupName(' ', 'Existing')).toBe('Existing')
|
||||
})
|
||||
|
||||
it('normalizes persisted groups and drops malformed entries', () => {
|
||||
const groups = normalizeProjectGroups([
|
||||
{ id: 'b', name: 'B', tabOrder: 2 },
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
tabOrder: 1,
|
||||
parentGroupId: 'missing',
|
||||
createdFrom: 'folder-scan',
|
||||
isCollapsed: true
|
||||
},
|
||||
{ id: 'a', name: 'duplicate' },
|
||||
{ name: 'missing id' }
|
||||
])
|
||||
|
||||
expect(groups.map((group) => group.id)).toEqual(['a', 'b'])
|
||||
expect(groups[0]).toMatchObject({
|
||||
createdFrom: 'folder-scan',
|
||||
isCollapsed: true,
|
||||
parentGroupId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('clears repo memberships whose group no longer exists', () => {
|
||||
const groups = [createProjectGroup({ name: 'Known', createdFrom: 'manual', tabOrder: 0 })]
|
||||
const repos = clearMissingProjectGroupMemberships(
|
||||
[
|
||||
repo({ id: 'known', projectGroupId: groups[0].id }),
|
||||
repo({ id: 'missing', projectGroupId: 'x' })
|
||||
],
|
||||
groups
|
||||
)
|
||||
|
||||
expect(repos.find((entry) => entry.id === 'known')?.projectGroupId).toBe(groups[0].id)
|
||||
expect(repos.find((entry) => entry.id === 'missing')?.projectGroupId).toBeNull()
|
||||
})
|
||||
|
||||
it('computes the next order inside a group independently from ungrouped repos', () => {
|
||||
expect(
|
||||
getNextProjectGroupOrder(
|
||||
[
|
||||
repo({ id: 'a', projectGroupId: 'g', projectGroupOrder: 2 }),
|
||||
repo({ id: 'b', projectGroupId: null, projectGroupOrder: 9 })
|
||||
],
|
||||
'g'
|
||||
)
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it('collects descendant group ids for subtree deletion', () => {
|
||||
expect(
|
||||
[
|
||||
...getProjectGroupSubtreeIds(
|
||||
[
|
||||
{ id: 'root', parentGroupId: null },
|
||||
{ id: 'child', parentGroupId: 'root' },
|
||||
{ id: 'grandchild', parentGroupId: 'child' },
|
||||
{ id: 'sibling', parentGroupId: null }
|
||||
],
|
||||
'root'
|
||||
)
|
||||
].sort()
|
||||
).toEqual(['child', 'grandchild', 'root'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types'
|
||||
|
||||
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
|
||||
|
||||
function createProjectGroupId(): string {
|
||||
const randomUUID = globalThis.crypto?.randomUUID
|
||||
if (randomUUID) {
|
||||
return randomUUID.call(globalThis.crypto)
|
||||
}
|
||||
return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string {
|
||||
const trimmed = name.trim()
|
||||
return trimmed.length > 0 ? trimmed : fallback
|
||||
}
|
||||
|
||||
export function createProjectGroup(input: {
|
||||
name: string
|
||||
parentPath?: string | null
|
||||
parentGroupId?: string | null
|
||||
createdFrom: ProjectGroupCreatedFrom
|
||||
tabOrder: number
|
||||
now?: number
|
||||
}): ProjectGroup {
|
||||
const now = input.now ?? Date.now()
|
||||
return {
|
||||
id: createProjectGroupId(),
|
||||
name: normalizeProjectGroupName(input.name),
|
||||
parentPath: input.parentPath ?? null,
|
||||
parentGroupId: input.parentGroupId ?? null,
|
||||
createdFrom: input.createdFrom,
|
||||
tabOrder: input.tabOrder,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const groups: ProjectGroup[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const candidate of value) {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
continue
|
||||
}
|
||||
const raw = candidate as Partial<ProjectGroup>
|
||||
if (typeof raw.id !== 'string' || seen.has(raw.id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(raw.id)
|
||||
const now = Date.now()
|
||||
groups.push({
|
||||
id: raw.id,
|
||||
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
|
||||
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
|
||||
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
|
||||
createdFrom:
|
||||
raw.createdFrom === 'manual' ||
|
||||
raw.createdFrom === 'folder-scan' ||
|
||||
raw.createdFrom === 'migration'
|
||||
? raw.createdFrom
|
||||
: 'manual',
|
||||
tabOrder:
|
||||
typeof raw.tabOrder === 'number' && Number.isFinite(raw.tabOrder) ? raw.tabOrder : 0,
|
||||
isCollapsed: raw.isCollapsed === true,
|
||||
color: typeof raw.color === 'string' ? raw.color : null,
|
||||
createdAt:
|
||||
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
|
||||
updatedAt:
|
||||
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
|
||||
})
|
||||
}
|
||||
groups.sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
const groupIds = new Set(groups.map((group) => group.id))
|
||||
for (const group of groups) {
|
||||
if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) {
|
||||
group.parentGroupId = null
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function clearMissingProjectGroupMemberships(repos: Repo[], groups: ProjectGroup[]): Repo[] {
|
||||
const groupIds = new Set(groups.map((group) => group.id))
|
||||
return repos.map((repo) =>
|
||||
repo.projectGroupId && !groupIds.has(repo.projectGroupId)
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
}
|
||||
|
||||
export function getProjectGroupSubtreeIds(
|
||||
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[],
|
||||
rootGroupId: string
|
||||
): Set<string> {
|
||||
const childGroupsByParentId = new Map<string, string[]>()
|
||||
for (const group of groups) {
|
||||
if (!group.parentGroupId) {
|
||||
continue
|
||||
}
|
||||
childGroupsByParentId.set(group.parentGroupId, [
|
||||
...(childGroupsByParentId.get(group.parentGroupId) ?? []),
|
||||
group.id
|
||||
])
|
||||
}
|
||||
|
||||
const subtreeIds = new Set<string>()
|
||||
const pending = [rootGroupId]
|
||||
while (pending.length > 0) {
|
||||
const groupId = pending.pop()!
|
||||
if (subtreeIds.has(groupId)) {
|
||||
continue
|
||||
}
|
||||
subtreeIds.add(groupId)
|
||||
pending.push(...(childGroupsByParentId.get(groupId) ?? []))
|
||||
}
|
||||
return subtreeIds
|
||||
}
|
||||
|
||||
export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string | null): number {
|
||||
let max = -1
|
||||
for (const repo of repos) {
|
||||
if ((repo.projectGroupId ?? null) !== groupId) {
|
||||
continue
|
||||
}
|
||||
const order = repo.projectGroupOrder
|
||||
if (typeof order === 'number' && Number.isFinite(order)) {
|
||||
max = Math.max(max, order)
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
|
@ -104,10 +104,68 @@ export type Repo = {
|
|||
* "what to link", the global flag is the "whether to link at all" switch.
|
||||
* Undefined/empty means no symlinks are created for this repo. */
|
||||
symlinkPaths?: string[]
|
||||
/** Durable sidebar-only repo organization. Execution remains repo-scoped. */
|
||||
projectGroupId?: string | null
|
||||
/** User-authored ordering inside the project group or ungrouped bucket. */
|
||||
projectGroupOrder?: number
|
||||
/** Repo-specific source-control AI overrides. Missing fields inherit global settings. */
|
||||
sourceControlAi?: RepoSourceControlAiOverrides
|
||||
}
|
||||
|
||||
export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration'
|
||||
|
||||
export type ProjectGroup = {
|
||||
id: string
|
||||
name: string
|
||||
parentPath: string | null
|
||||
parentGroupId: string | null
|
||||
createdFrom: ProjectGroupCreatedFrom
|
||||
tabOrder: number
|
||||
isCollapsed: boolean
|
||||
color: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type NestedRepoScanOptions = {
|
||||
maxDepth?: number
|
||||
maxRepos?: number
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type NestedRepoCandidate = {
|
||||
path: string
|
||||
displayName: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export type NestedRepoScanResult = {
|
||||
selectedPath: string
|
||||
selectedPathKind: 'git_repo' | 'non_git_folder'
|
||||
repos: NestedRepoCandidate[]
|
||||
truncated: boolean
|
||||
timedOut: boolean
|
||||
durationMs: number
|
||||
maxDepth: number
|
||||
}
|
||||
|
||||
export type ProjectGroupImportMode = 'group' | 'separate'
|
||||
|
||||
export type ProjectGroupImportProjectResult = {
|
||||
path: string
|
||||
projectId?: string
|
||||
status: 'imported' | 'already-known' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ProjectGroupImportResult = {
|
||||
group?: ProjectGroup
|
||||
projects: ProjectGroupImportProjectResult[]
|
||||
importedCount: number
|
||||
alreadyKnownCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
||||
export type SetupDecision = 'inherit' | 'run' | 'skip'
|
||||
export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both'
|
||||
|
|
@ -2324,6 +2382,7 @@ export type LegacyPaneKeyAliasEntry = {
|
|||
export type PersistedState = {
|
||||
schemaVersion: number
|
||||
repos: Repo[]
|
||||
projectGroups: ProjectGroup[]
|
||||
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
|
||||
* presets are managed from the new-workspace composer and repo settings. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import { execFileSync } from 'child_process'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { mkdtemp } from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
async function createNestedRepoFixture(): Promise<{
|
||||
parentPath: string
|
||||
projectPaths: string[]
|
||||
groupName: string
|
||||
}> {
|
||||
const parentPath = await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-folder-setup-'))
|
||||
tempRoots.push(parentPath)
|
||||
const repoNames = ['api-service', 'web-client']
|
||||
const projectPaths = repoNames.map((name) => path.join(parentPath, name))
|
||||
|
||||
for (const repoPath of projectPaths) {
|
||||
mkdirSync(repoPath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], {
|
||||
cwd: repoPath,
|
||||
stdio: 'pipe'
|
||||
})
|
||||
execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: repoPath, stdio: 'pipe' })
|
||||
writeFileSync(path.join(repoPath, 'README.md'), `# ${path.basename(repoPath)}\n`)
|
||||
execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' })
|
||||
execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
return {
|
||||
parentPath,
|
||||
projectPaths,
|
||||
groupName: path.basename(parentPath)
|
||||
}
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function chooseFolderInNativeDialog(
|
||||
electronApp: ElectronApplication,
|
||||
folderPath: string
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(({ dialog }, selectedPath) => {
|
||||
dialog.showOpenDialog = async () => ({
|
||||
canceled: false,
|
||||
filePaths: [selectedPath],
|
||||
bookmarks: []
|
||||
})
|
||||
}, folderPath)
|
||||
}
|
||||
|
||||
test.describe('Folder setup', () => {
|
||||
test('imports nested repositories from the add-project dialog as a project group', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const fixture = await createNestedRepoFixture()
|
||||
await chooseFolderInNativeDialog(electronApp, fixture.parentPath)
|
||||
|
||||
await orcaPage
|
||||
.getByRole('button', { name: /Add Project/i })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = orcaPage.getByRole('dialog', { name: /Add a project/i })
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByRole('button', { name: /Browse folder/i }).click()
|
||||
|
||||
const importDialog = orcaPage.getByRole('dialog', { name: /Import as project group/i })
|
||||
await expect(
|
||||
importDialog.getByRole('heading', { name: /Import as project group/i })
|
||||
).toBeVisible()
|
||||
await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible()
|
||||
await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible()
|
||||
await expect(
|
||||
importDialog.getByRole('button', { name: /Import as project group/i })
|
||||
).toBeEnabled()
|
||||
await importDialog.getByRole('button', { name: /Import as project group/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate((args) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
const importedRepos = state.repos
|
||||
.filter((repo) => args.projectPaths.includes(repo.path))
|
||||
.sort((left, right) => left.displayName.localeCompare(right.displayName))
|
||||
const group = state.projectGroups.find((entry) => entry.parentPath === args.parentPath)
|
||||
return {
|
||||
groupName: group?.name ?? null,
|
||||
repoNames: importedRepos.map((repo) => repo.displayName),
|
||||
reposInCreatedGroup:
|
||||
group !== undefined &&
|
||||
importedRepos.every((repo) => repo.projectGroupId === group.id),
|
||||
projectGroupOrders: importedRepos.map((repo) => repo.projectGroupOrder ?? null)
|
||||
}
|
||||
}, fixture),
|
||||
{
|
||||
timeout: 20_000,
|
||||
message: 'nested repos were not imported into a project group'
|
||||
}
|
||||
)
|
||||
.toEqual({
|
||||
groupName: fixture.groupName,
|
||||
repoNames: ['api-service', 'web-client'],
|
||||
reposInCreatedGroup: true,
|
||||
projectGroupOrders: [0, 1]
|
||||
})
|
||||
|
||||
await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
state?.closeModal()
|
||||
state?.setGroupBy('repo')
|
||||
})
|
||||
await expect(orcaPage.getByText(fixture.groupName)).toBeVisible()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue