From bfda940e563b047f88289fd3b137722a331fcf06 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:15:10 -0700 Subject: [PATCH] Add option to remove child projects when deleting repo groups (#4702) Co-authored-by: Orca --- .../sidebar/ProjectGroupDeleteDialog.test.tsx | 132 +++++++++++++++++ .../sidebar/ProjectGroupDeleteDialog.tsx | 110 +++++++++++++- .../src/components/sidebar/WorktreeList.tsx | 91 +++++++++++- src/renderer/src/components/ui/checkbox.tsx | 29 ++++ src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- .../project-group-removal-targets.test.ts | 81 +++++++++++ .../slices/project-group-removal-targets.ts | 37 +++++ .../store/slices/repos-project-groups.test.ts | 137 +++++++++++++++++- src/renderer/src/store/slices/repos.ts | 95 ++++++++++++ 13 files changed, 769 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx create mode 100644 src/renderer/src/components/ui/checkbox.tsx create mode 100644 src/renderer/src/store/slices/project-group-removal-targets.test.ts create mode 100644 src/renderer/src/store/slices/project-group-removal-targets.ts diff --git a/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx new file mode 100644 index 000000000..736ce012f --- /dev/null +++ b/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx @@ -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> = {} +): void { + act(() => { + root.render( + + ) + }) +} + +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((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() + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx b/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx index 4dfeb1a64..0a864ce9a 100644 --- a/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx +++ b/src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx @@ -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 } @@ -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(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({ { + 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() + }} > @@ -84,25 +114,86 @@ export function ProjectGroupDeleteDialog({ )} - {translate('auto.components.sidebar.ProjectGroupDeleteDialog.69f5cb97d0', 'Delete')} - {groupName}{' '} - {translate( - 'auto.components.sidebar.ProjectGroupDeleteDialog.9be10d49ea', - 'and ungroup its projects.' - )} + {translate('auto.components.sidebar.ProjectGroupDeleteDialog.69f5cb97d0', 'Delete')}{' '} + {groupName}. + {projectCount > 0 && ( +
+ {projectNames.length > 0 && ( +
+
+ {translate( + 'auto.components.sidebar.ProjectGroupDeleteDialog.0e0e6764af', + 'Contained projects' + )} +
+
    + {projectNames.slice(0, 4).map((projectName, index) => ( +
  • + {projectName} +
  • + ))} + {projectNames.length > 4 ? ( +
  • + +{projectNames.length - 4}{' '} + {translate( + 'auto.components.sidebar.ProjectGroupDeleteDialog.ad407c2d55', + 'more' + )} +
  • + ) : null} +
+
+ )} +
+ onRemoveContainedProjectsChange(checked === true)} + aria-describedby={`${removeProjectsId}-description`} + className="mt-0.5" + /> + + + + {translate( + 'auto.components.sidebar.ProjectGroupDeleteDialog.55f75628c0', + 'Project folders on disk are not deleted.' + )} + + +
+
+ )}