Add option to remove child projects when deleting repo groups (#4702)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-10 20:15:10 -07:00 committed by GitHub
parent 1ef273c018
commit bfda940e56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 769 additions and 23 deletions

View File

@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
document.body.innerHTML = ''
})
function renderDialog(
overrides: Partial<React.ComponentProps<typeof ProjectGroupDeleteDialog>> = {}
): void {
act(() => {
root.render(
<ProjectGroupDeleteDialog
open={true}
groupName="Platform"
projectCount={2}
projectNames={['API', 'Web app']}
removeContainedProjects={false}
onRemoveContainedProjectsChange={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
{...overrides}
/>
)
})
}
function findButton(label: string): HTMLButtonElement {
const button = Array.from(document.body.querySelectorAll('button')).find((entry) =>
entry.textContent?.includes(label)
)
if (!button) {
throw new Error(`Button not found: ${label}`)
}
return button
}
function getCheckbox(): HTMLButtonElement {
const checkbox = document.body.querySelector('[role="checkbox"]')
if (!(checkbox instanceof HTMLButtonElement)) {
throw new Error('Checkbox not rendered')
}
return checkbox
}
describe('ProjectGroupDeleteDialog', () => {
it('omits the contained project panel for empty groups', () => {
renderDialog({ projectCount: 0 })
expect(document.body.querySelector('[role="checkbox"]')).toBeNull()
expect(document.body.textContent).not.toContain('contained project')
})
it('renders compact contained project handling and reports remove intent', () => {
const onRemoveContainedProjectsChange = vi.fn()
renderDialog({ onRemoveContainedProjectsChange })
expect(document.body.textContent).toContain('Delete Platform.')
expect(document.body.textContent).toContain('Contained projects')
expect(document.body.textContent).not.toContain('unless selected below')
expect(getCheckbox().getAttribute('aria-checked')).toBe('false')
expect(document.body.textContent).toContain('Remove 2 contained projects')
expect(document.body.textContent).not.toContain('Remove 2 contained projects from Orca')
expect(document.body.textContent).toContain('Project folders on disk are not deleted.')
expect(document.body.textContent).toContain('API')
expect(document.body.textContent).toContain('Web app')
act(() => {
getCheckbox().click()
})
expect(onRemoveContainedProjectsChange).toHaveBeenCalledWith(true)
})
it('focuses the delete group action when opened', () => {
renderDialog()
expect(document.activeElement).toBe(findButton('Delete Group'))
})
it('keeps the panel copy and destructive action label stable when project removal is selected', () => {
renderDialog({ removeContainedProjects: true })
expect(document.body.textContent).toContain('Delete Platform.')
expect(document.body.textContent).not.toContain('will stay in Orca')
expect(document.body.textContent).not.toContain('will be removed from Orca')
expect(document.body.textContent).not.toContain('unless selected below')
expect(getCheckbox().getAttribute('aria-checked')).toBe('true')
expect(findButton('Delete Group')).toBeTruthy()
expect(document.body.textContent).not.toContain('Delete Group and Remove Projects')
})
it('disables project choices, cancel, and delete actions while deleting', async () => {
let finishConfirm: () => void = () => undefined
const onConfirm = vi.fn(
() =>
new Promise<void>((resolve) => {
finishConfirm = resolve
})
)
renderDialog({ onConfirm })
act(() => {
findButton('Delete Group').click()
})
expect(getCheckbox().disabled).toBe(true)
expect(findButton('Cancel').disabled).toBe(true)
expect(findButton('Deleting...').disabled).toBe(true)
await act(async () => {
finishConfirm()
await Promise.resolve()
})
})
})

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useId, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
@ -8,11 +8,17 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { translate } from '@/i18n/i18n'
type ProjectGroupDeleteDialogProps = {
open: boolean
groupName: string
projectCount: number
projectNames: string[]
removeContainedProjects: boolean
onRemoveContainedProjectsChange: (removeContainedProjects: boolean) => void
onOpenChange: (open: boolean) => void
onConfirm: () => Promise<void> | void
}
@ -20,12 +26,29 @@ type ProjectGroupDeleteDialogProps = {
export function ProjectGroupDeleteDialog({
open,
groupName,
projectCount,
projectNames,
removeContainedProjects,
onRemoveContainedProjectsChange,
onOpenChange,
onConfirm
}: ProjectGroupDeleteDialogProps): React.JSX.Element {
const [deleting, setDeleting] = useState(false)
const [wasOpen, setWasOpen] = useState(open)
const mountedRef = useRef(true)
const confirmButtonRef = useRef<HTMLButtonElement>(null)
const removeProjectsId = useId()
const removeContainedProjectCopy =
projectCount === 1
? translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.removeContainedProjectSingular',
'Remove 1 contained project'
)
: translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.removeContainedProjectPlural',
'Remove {{value0}} contained projects',
{ value0: projectCount }
)
const handleDialogContentRef = useCallback((node: HTMLDivElement | null): void => {
// Why: deleting can resolve after the dialog closes; the content ref keeps
@ -65,6 +88,9 @@ export function ProjectGroupDeleteDialog({
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && deleting) {
return
}
if (!nextOpen) {
setDeleting(false)
}
@ -75,6 +101,10 @@ export function ProjectGroupDeleteDialog({
ref={handleDialogContentRef}
className="max-w-sm sm:max-w-sm"
showCloseButton={false}
onOpenAutoFocus={(event) => {
event.preventDefault()
confirmButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle className="text-sm">
@ -84,25 +114,86 @@ export function ProjectGroupDeleteDialog({
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate('auto.components.sidebar.ProjectGroupDeleteDialog.69f5cb97d0', 'Delete')}
<span className="break-all font-medium text-foreground">{groupName}</span>{' '}
{translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.9be10d49ea',
'and ungroup its projects.'
)}
{translate('auto.components.sidebar.ProjectGroupDeleteDialog.69f5cb97d0', 'Delete')}{' '}
<span className="break-all font-medium text-foreground">{groupName}</span>.
</DialogDescription>
</DialogHeader>
{projectCount > 0 && (
<div className="space-y-2 text-xs">
{projectNames.length > 0 && (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2">
<div className="mb-1 text-[11px] font-medium uppercase tracking-[0.05em] text-muted-foreground">
{translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.0e0e6764af',
'Contained projects'
)}
</div>
<ul
className="min-w-0 space-y-0.5 text-foreground"
aria-label={translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.0e0e6764af',
'Contained projects'
)}
>
{projectNames.slice(0, 4).map((projectName, index) => (
<li key={`${projectName}:${index}`} className="truncate" title={projectName}>
{projectName}
</li>
))}
{projectNames.length > 4 ? (
<li className="text-muted-foreground">
+{projectNames.length - 4}{' '}
{translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.ad407c2d55',
'more'
)}
</li>
) : null}
</ul>
</div>
)}
<div className="flex w-full items-start gap-2 rounded-sm px-1 py-1 text-foreground/85">
<Checkbox
id={removeProjectsId}
checked={removeContainedProjects}
disabled={deleting}
onCheckedChange={(checked) => onRemoveContainedProjectsChange(checked === true)}
aria-describedby={`${removeProjectsId}-description`}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<Label
htmlFor={removeProjectsId}
className="block cursor-pointer text-xs leading-4 font-medium"
>
{removeContainedProjectCopy}
</Label>
<span
id={`${removeProjectsId}-description`}
className="mt-0.5 block text-muted-foreground"
>
{translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.55f75628c0',
'Project folders on disk are not deleted.'
)}
</span>
</span>
</div>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
className="text-xs"
disabled={deleting}
onClick={() => onOpenChange(false)}
>
{translate('auto.components.sidebar.ProjectGroupDeleteDialog.ca65b78f78', 'Cancel')}
</Button>
<Button
ref={confirmButtonRef}
type="button"
variant="destructive"
size="sm"
@ -115,7 +206,10 @@ export function ProjectGroupDeleteDialog({
'auto.components.sidebar.ProjectGroupDeleteDialog.2c14ce677a',
'Deleting...'
)
: translate('auto.components.sidebar.ProjectGroupDeleteDialog.69f5cb97d0', 'Delete')}
: translate(
'auto.components.sidebar.ProjectGroupDeleteDialog.fec7e9c8ae',
'Delete Group'
)}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -174,6 +174,7 @@ import { getRepositoryIconSectionId } from '@/components/settings/repository-set
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog'
import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
effectiveExternalWorktreeVisibility,
@ -193,6 +194,7 @@ import {
getProjectGroupHeaderPaddingLeft,
getWorktreeCardContentIndent
} from './worktree-list-indentation'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export {
@ -207,6 +209,7 @@ type ProjectGroupNameDialogState =
type ProjectGroupDeleteDialogState = {
groupId: string
groupName: string
removeContainedProjects: boolean
}
// How long to wait after a sortEpoch bump before actually re-sorting.
@ -4204,7 +4207,9 @@ const WorktreeList = React.memo(function WorktreeList({
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 deleteProjectGroupWithContainedProjects = useAppStore(
(s) => s.deleteProjectGroupWithContainedProjects
)
const [projectGroupNameDialog, setProjectGroupNameDialog] =
useState<ProjectGroupNameDialogState | null>(null)
const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] =
@ -4252,16 +4257,86 @@ const WorktreeList = React.memo(function WorktreeList({
[createProjectGroup, moveProjectToGroup, projectGroupNameDialog, updateProjectGroup]
)
const projectGroupDeleteTargets = useMemo(() => {
if (!projectGroupDeleteDialog) {
return null
}
return selectProjectGroupRemovalTargets(projectGroups, repos, projectGroupDeleteDialog.groupId)
}, [projectGroupDeleteDialog, projectGroups, repos])
const projectGroupDeleteProjectCount = projectGroupDeleteTargets?.projectIds.length ?? 0
const projectGroupDeleteProjectNames = useMemo(
() =>
(projectGroupDeleteTargets?.projectIds ?? []).map(
(projectId) => repoMap.get(projectId)?.displayName ?? projectId
),
[projectGroupDeleteTargets, repoMap]
)
const projectGroupRemoveContainedProjects =
projectGroupDeleteProjectCount > 0 && projectGroupDeleteDialog?.removeContainedProjects === true
const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => {
setProjectGroupDeleteDialog({ groupId, groupName })
setProjectGroupDeleteDialog({ groupId, groupName, removeContainedProjects: false })
}, [])
const handleConfirmDeleteProjectGroup = useCallback(async () => {
if (!projectGroupDeleteDialog) {
return
}
await deleteProjectGroup(projectGroupDeleteDialog.groupId)
}, [deleteProjectGroup, projectGroupDeleteDialog])
try {
const result = await deleteProjectGroupWithContainedProjects(
projectGroupDeleteDialog.groupId,
{
removeContainedProjects: projectGroupRemoveContainedProjects
}
)
// Why: a missing group is already in the desired end state, so close
// quietly; only a real delete failure warrants an error toast.
if (result.status === 'group-delete-failed') {
toast.error(
translate(
'auto.components.sidebar.WorktreeList.groupDeleteFailed',
'Failed to delete group'
),
{
description: translate(
'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc',
'Something went wrong while deleting the group. No projects were removed.'
)
}
)
return
}
if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) {
const failedCount = result.failedProjectRemovals.length
const requestedCount = result.requestedProjectIds.length
toast.error(
translate(
'auto.components.sidebar.WorktreeList.b667b59632',
'Some projects could not be removed from Orca'
),
{
description: translate(
'auto.components.sidebar.WorktreeList.f94466bc39',
'{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.',
{
value0: failedCount,
value1: requestedCount,
value2: requestedCount === 1 ? '' : 's'
}
)
}
)
}
} finally {
// Why: deleting contained projects can empty the sidebar and unmount this
// dialog before its own close handler runs, so the parent owns cleanup.
setProjectGroupDeleteDialog(null)
}
}, [
deleteProjectGroupWithContainedProjects,
projectGroupRemoveContainedProjects,
projectGroupDeleteDialog
])
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
@ -4578,6 +4653,14 @@ const WorktreeList = React.memo(function WorktreeList({
<ProjectGroupDeleteDialog
open={projectGroupDeleteDialog !== null}
groupName={projectGroupDeleteDialog?.groupName ?? ''}
projectCount={projectGroupDeleteProjectCount}
projectNames={projectGroupDeleteProjectNames}
removeContainedProjects={projectGroupRemoveContainedProjects}
onRemoveContainedProjectsChange={(removeContainedProjects) => {
setProjectGroupDeleteDialog((current) =>
current ? { ...current, removeContainedProjects } : current
)
}}
onOpenChange={(open) => {
if (!open) {
setProjectGroupDeleteDialog(null)

View File

@ -0,0 +1,29 @@
'use client'
import * as React from 'react'
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer size-4 shrink-0 rounded-[4px] border border-border bg-background shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -3214,7 +3214,15 @@
"9be10d49ea": "and ungroup its projects.",
"69f5cb97d0": "Delete",
"591f330288": "Delete Project Group",
"2c14ce677a": "Deleting..."
"2c14ce677a": "Deleting...",
"0e0e6764af": "Contained projects",
"ad407c2d55": "more",
"removeContainedProjectSingular": "Remove 1 contained project",
"removeContainedProjectPlural": "Remove {{value0}} contained projects",
"eeabb8e8e4": "Remove {{value0}} contained {{value1}} from Orca",
"55f75628c0": "Project folders on disk are not deleted.",
"897e5d3d4c": "Delete Group and Remove Projects",
"fec7e9c8ae": "Delete Group"
},
"ProjectGroupNameDialog": {
"d99a034073": "Cancel",
@ -3565,7 +3573,11 @@
"20bebf9c7f": "Show {{value0}} child workspace",
"c1f4a31623": "Show {{value0}} child workspaces",
"e97297cb75": "Hide {{value0}} child workspace",
"0cd15956d4": "Hide {{value0}} child workspaces"
"0cd15956d4": "Hide {{value0}} child workspaces",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
"groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed."
},
"WorktreeMetaDialog": {
"3db0a2a593": "Cancel",

View File

@ -3214,7 +3214,15 @@
"9be10d49ea": "y desagrupar sus proyectos.",
"69f5cb97d0": "Borrar",
"591f330288": "Eliminar grupo de proyectos",
"2c14ce677a": "Eliminando..."
"2c14ce677a": "Eliminando...",
"0e0e6764af": "Proyectos contenidos",
"ad407c2d55": "más",
"removeContainedProjectSingular": "Quitar 1 proyecto contenido",
"removeContainedProjectPlural": "Quitar {{value0}} proyectos contenidos",
"eeabb8e8e4": "Quitar {{value0}} {{value1}} contenidos de Orca",
"55f75628c0": "Las carpetas de los proyectos en el disco no se eliminan.",
"897e5d3d4c": "Eliminar grupo y quitar proyectos",
"fec7e9c8ae": "Eliminar grupo"
},
"ProjectGroupNameDialog": {
"d99a034073": "Cancelar",
@ -3565,7 +3573,11 @@
"20bebf9c7f": "Mostrar {{value0}} espacio de trabajo secundario",
"c1f4a31623": "Mostrar {{value0}} espacios de trabajo secundarios",
"e97297cb75": "Ocultar {{value0}} espacio de trabajo secundario",
"0cd15956d4": "Ocultar {{value0}} espacios de trabajo secundarios"
"0cd15956d4": "Ocultar {{value0}} espacios de trabajo secundarios",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
"groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed."
},
"WorktreeMetaDialog": {
"3db0a2a593": "Cancelar",

View File

@ -3195,7 +3195,15 @@
"9be10d49ea": "そしてプロジェクトのグループ化を解除します。",
"69f5cb97d0": "削除",
"591f330288": "プロジェクトグループの削除",
"2c14ce677a": "削除中..."
"2c14ce677a": "削除中...",
"0e0e6764af": "含まれるプロジェクト",
"ad407c2d55": "件以上",
"removeContainedProjectSingular": "含まれるプロジェクトを1件削除",
"removeContainedProjectPlural": "含まれるプロジェクトを{{value0}}件削除",
"eeabb8e8e4": "含まれる{{value1}}を{{value0}}件Orcaから削除",
"55f75628c0": "ディスク上のプロジェクトフォルダーは削除されません。",
"897e5d3d4c": "グループを削除してプロジェクトを削除",
"fec7e9c8ae": "グループを削除"
},
"ProjectGroupNameDialog": {
"d99a034073": "キャンセル",
@ -3546,7 +3554,11 @@
"20bebf9c7f": "{{value0}} 個の子ワークスペースを表示",
"c1f4a31623": "{{value0}} 個の子ワークスペースを表示",
"e97297cb75": "{{value0}} 個の子ワークスペースを非表示",
"0cd15956d4": "{{value0}} 個の子ワークスペースを非表示"
"0cd15956d4": "{{value0}} 個の子ワークスペースを非表示",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
"groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed."
},
"WorktreeMetaDialog": {
"3db0a2a593": "キャンセル",

View File

@ -3195,7 +3195,15 @@
"9be10d49ea": "해당 프로젝트의 그룹을 해제합니다.",
"69f5cb97d0": "삭제",
"591f330288": "프로젝트 그룹 삭제",
"2c14ce677a": "삭제 중..."
"2c14ce677a": "삭제 중...",
"0e0e6764af": "포함된 프로젝트",
"ad407c2d55": "개 더",
"removeContainedProjectSingular": "포함된 프로젝트 1개 제거",
"removeContainedProjectPlural": "포함된 프로젝트 {{value0}}개 제거",
"eeabb8e8e4": "Orca에서 포함된 {{value1}} {{value0}}개 제거",
"55f75628c0": "디스크의 프로젝트 폴더는 삭제되지 않습니다.",
"897e5d3d4c": "그룹 삭제 및 프로젝트 제거",
"fec7e9c8ae": "그룹 삭제"
},
"ProjectGroupNameDialog": {
"d99a034073": "취소",
@ -3546,7 +3554,11 @@
"20bebf9c7f": "{{value0}}개 하위 워크스페이스 표시",
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
"0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기"
"0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
"groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed."
},
"WorktreeMetaDialog": {
"3db0a2a593": "취소",

View File

@ -3195,7 +3195,15 @@
"9be10d49ea": "并取消其项目的分组。",
"69f5cb97d0": "删除",
"591f330288": "删除项目组",
"2c14ce677a": "正在删除..."
"2c14ce677a": "正在删除...",
"0e0e6764af": "包含的项目",
"ad407c2d55": "更多",
"removeContainedProjectSingular": "移除 1 个包含的项目",
"removeContainedProjectPlural": "移除 {{value0}} 个包含的项目",
"eeabb8e8e4": "从 Orca 移除 {{value0}} 个包含的{{value1}}",
"55f75628c0": "不会删除磁盘上的项目文件夹。",
"897e5d3d4c": "删除组并移除项目",
"fec7e9c8ae": "删除组"
},
"ProjectGroupNameDialog": {
"d99a034073": "取消",
@ -3546,7 +3554,11 @@
"20bebf9c7f": "显示 {{value0}} 个子工作区",
"c1f4a31623": "显示 {{value0}} 个子工作区",
"e97297cb75": "隐藏 {{value0}} 个子工作区",
"0cd15956d4": "隐藏 {{value0}} 个子工作区"
"0cd15956d4": "隐藏 {{value0}} 个子工作区",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
"groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed."
},
"WorktreeMetaDialog": {
"3db0a2a593": "取消",

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { ProjectGroup, Repo } from '../../../../shared/types'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
const rootGroup: ProjectGroup = {
id: 'root',
name: 'Root',
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const childGroup: ProjectGroup = {
...rootGroup,
id: 'child',
name: 'Child',
parentGroupId: rootGroup.id,
tabOrder: 1
}
const siblingGroup: ProjectGroup = {
...rootGroup,
id: 'sibling',
name: 'Sibling',
tabOrder: 2
}
function makeRepo(id: string, projectGroupId: string | null): Repo {
return {
id,
path: `/${id}`,
displayName: id,
badgeColor: '#000',
addedAt: 1,
projectGroupId
}
}
describe('selectProjectGroupRemovalTargets', () => {
it('selects direct and nested child projects in repo order', () => {
const result = selectProjectGroupRemovalTargets(
[rootGroup, childGroup, siblingGroup],
[
makeRepo('direct', rootGroup.id),
makeRepo('nested', childGroup.id),
makeRepo('sibling', siblingGroup.id),
makeRepo('ungrouped', null)
],
rootGroup.id
)
expect(result.groupExists).toBe(true)
expect([...result.deletedGroupIds].sort()).toEqual([childGroup.id, rootGroup.id])
expect(result.projectIds).toEqual(['direct', 'nested'])
})
it('returns an empty project list for empty groups', () => {
const result = selectProjectGroupRemovalTargets([rootGroup], [], rootGroup.id)
expect(result.groupExists).toBe(true)
expect([...result.deletedGroupIds]).toEqual([rootGroup.id])
expect(result.projectIds).toEqual([])
})
it('does not synthesize targets for a missing group', () => {
const result = selectProjectGroupRemovalTargets(
[rootGroup],
[makeRepo('direct', rootGroup.id)],
'missing'
)
expect(result.groupExists).toBe(false)
expect([...result.deletedGroupIds]).toEqual([])
expect(result.projectIds).toEqual([])
})
})

View File

@ -0,0 +1,37 @@
import type { ProjectGroup, Repo } from '../../../../shared/types'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
export type ProjectGroupRemovalTargets = {
groupExists: boolean
deletedGroupIds: Set<string>
projectIds: string[]
}
export function selectProjectGroupRemovalTargets(
projectGroups: readonly ProjectGroup[],
repos: readonly Repo[],
groupId: string
): ProjectGroupRemovalTargets {
const groupExists = projectGroups.some((group) => group.id === groupId)
if (!groupExists) {
return {
groupExists: false,
deletedGroupIds: new Set(),
projectIds: []
}
}
const deletedGroupIds = getProjectGroupSubtreeIds(projectGroups, groupId)
const projectIds: string[] = []
for (const repo of repos) {
if (repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)) {
projectIds.push(repo.id)
}
}
return {
groupExists: true,
deletedGroupIds,
projectIds
}
}

View File

@ -29,6 +29,8 @@ const projectGroup: ProjectGroup = {
}
const reposList = vi.fn()
const reposRemove = vi.fn()
const ptyKill = vi.fn()
const projectGroupsList = vi.fn()
const projectGroupsCreate = vi.fn()
const projectGroupsDelete = vi.fn()
@ -43,6 +45,9 @@ const runtimeEnvironmentTransportCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
reposRemove.mockReset()
reposRemove.mockResolvedValue(undefined)
ptyKill.mockReset()
projectGroupsList.mockReset()
projectGroupsCreate.mockReset()
projectGroupsDelete.mockReset()
@ -60,8 +65,10 @@ beforeEach(() => {
vi.stubGlobal('window', {
api: {
repos: {
list: reposList
list: reposList,
remove: reposRemove
},
pty: { kill: ptyKill },
projectGroups: {
list: projectGroupsList,
create: projectGroupsCreate,
@ -327,4 +334,132 @@ describe('project group store routing', () => {
})
expect(projectGroupsDelete).not.toHaveBeenCalled()
})
it('deletes only the group when contained project removal is not requested', async () => {
projectGroupsDelete.mockResolvedValue(true)
const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }
const store = createTestStore()
store.setState({
projectGroups: [projectGroup],
repos: [groupedRepo]
})
await expect(
store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, {
removeContainedProjects: false
})
).resolves.toEqual({
status: 'deleted-group',
groupId: projectGroup.id,
requestedProjectIds: [],
removedProjectIds: [],
failedProjectRemovals: []
})
expect(reposRemove).not.toHaveBeenCalled()
expect(store.getState().repos).toMatchObject([{ id: 'direct', projectGroupId: null }])
})
it('removes direct and nested child projects after deleting a group', async () => {
const childGroup: ProjectGroup = {
...projectGroup,
id: 'child',
parentGroupId: projectGroup.id
}
const siblingRepo = { ...remoteRepo, id: 'sibling', projectGroupId: null }
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
projectGroups: [projectGroup, childGroup],
repos: [
{ ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id },
{ ...remoteRepo, id: 'nested', projectGroupId: childGroup.id },
siblingRepo
]
})
await expect(
store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, {
removeContainedProjects: true
})
).resolves.toEqual({
status: 'deleted-group',
groupId: projectGroup.id,
requestedProjectIds: ['direct', 'nested'],
removedProjectIds: ['direct', 'nested'],
failedProjectRemovals: []
})
expect(reposRemove).toHaveBeenCalledWith({ repoId: 'direct' })
expect(reposRemove).toHaveBeenCalledWith({ repoId: 'nested' })
expect(store.getState().repos).toEqual([siblingRepo])
})
it('does not remove contained projects when group deletion fails', async () => {
projectGroupsDelete.mockResolvedValue(false)
const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }
const store = createTestStore()
store.setState({
projectGroups: [projectGroup],
repos: [groupedRepo]
})
await expect(
store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, {
removeContainedProjects: true
})
).resolves.toEqual({
status: 'group-delete-failed',
groupId: projectGroup.id,
requestedProjectIds: ['direct'],
removedProjectIds: [],
failedProjectRemovals: []
})
expect(reposRemove).not.toHaveBeenCalled()
expect(store.getState().repos).toEqual([groupedRepo])
})
it('reports project removal failures by comparing store state after removeProject', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
reposRemove.mockImplementation(async ({ repoId }: { repoId: string }) => {
if (repoId === 'nested') {
throw new Error('remove failed')
}
})
const childGroup: ProjectGroup = {
...projectGroup,
id: 'child',
parentGroupId: projectGroup.id
}
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
projectGroups: [projectGroup, childGroup],
repos: [
{ ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id },
{ ...remoteRepo, id: 'nested', projectGroupId: childGroup.id }
]
})
await expect(
store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, {
removeContainedProjects: true
})
).resolves.toEqual({
status: 'deleted-group',
groupId: projectGroup.id,
requestedProjectIds: ['direct', 'nested'],
removedProjectIds: ['direct'],
failedProjectRemovals: [
{
projectId: 'nested',
reason: 'Project remained in Orca after removeProject completed.'
}
]
})
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['nested'])
consoleError.mockRestore()
})
})

View File

@ -15,6 +15,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
import { reconcileFetchedRepos } from './repo-identity-reconcile'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
@ -51,6 +52,31 @@ type NestedRepoScanControls = {
onProgress?: (scan: NestedRepoScanResult) => void
}
export type DeleteProjectGroupWithContainedProjectsOptions = {
removeContainedProjects: boolean
}
export type ProjectRemovalFailure = {
projectId: string
reason: string
}
export type DeleteProjectGroupWithContainedProjectsResult =
| {
status: 'deleted-group'
groupId: string
requestedProjectIds: string[]
removedProjectIds: string[]
failedProjectRemovals: ProjectRemovalFailure[]
}
| {
status: 'missing-group' | 'group-delete-failed'
groupId: string
requestedProjectIds: string[]
removedProjectIds: []
failedProjectRemovals: []
}
function normalizeNestedRepoScanResult(scan: NestedRepoScanResult): NestedRepoScanResult {
return {
...scan,
@ -136,6 +162,10 @@ export type RepoSlice = {
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
) => Promise<boolean>
deleteProjectGroup: (groupId: string) => Promise<boolean>
deleteProjectGroupWithContainedProjects: (
groupId: string,
options: DeleteProjectGroupWithContainedProjectsOptions
) => Promise<DeleteProjectGroupWithContainedProjectsResult>
moveProjectToGroup: (
projectId: string,
groupId: string | null,
@ -382,6 +412,71 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
deleteProjectGroupWithContainedProjects: async (groupId, options) => {
const targets = selectProjectGroupRemovalTargets(get().projectGroups, get().repos, groupId)
const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : []
if (!targets.groupExists) {
return {
status: 'missing-group',
groupId,
requestedProjectIds,
removedProjectIds: [],
failedProjectRemovals: []
}
}
const deleted = await get().deleteProjectGroup(groupId)
if (!deleted) {
return {
status: 'group-delete-failed',
groupId,
requestedProjectIds,
removedProjectIds: [],
failedProjectRemovals: []
}
}
if (!options.removeContainedProjects) {
return {
status: 'deleted-group',
groupId,
requestedProjectIds,
removedProjectIds: [],
failedProjectRemovals: []
}
}
const removedProjectIds: string[] = []
const failedProjectRemovals: ProjectRemovalFailure[] = []
for (const projectId of targets.projectIds) {
const existedBeforeRemoval = get().repos.some((repo) => repo.id === projectId)
try {
if (existedBeforeRemoval) {
await get().removeProject(projectId)
}
} catch (err) {
console.error('Failed to remove contained project:', err)
}
const stillExists = get().repos.some((repo) => repo.id === projectId)
if (stillExists) {
failedProjectRemovals.push({
projectId,
reason: 'Project remained in Orca after removeProject completed.'
})
} else {
removedProjectIds.push(projectId)
}
}
return {
status: 'deleted-group',
groupId,
requestedProjectIds,
removedProjectIds,
failedProjectRemovals
}
},
moveProjectToGroup: async (projectId, groupId, order) => {
try {
const target = getActiveRuntimeTarget(get().settings)