Add option to remove child projects when deleting repo groups (#4702)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
1ef273c018
commit
bfda940e56
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "キャンセル",
|
||||
|
|
|
|||
|
|
@ -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": "취소",
|
||||
|
|
|
|||
|
|
@ -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": "取消",
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue