Run nested workspace deletion in the background (#25975)

* Run confirmed nested worktree deletion in the background

* Reuse the translated workspace deletion failure title

* Supply launch arguments in the Codex session test fixture
This commit is contained in:
Neil
2026-10-06 15:30:49 -07:00
committed by GitHub
parent 72b84118e0
commit 6352ff3ff9
3 changed files with 86 additions and 14 deletions
@@ -173,6 +173,7 @@ async function startHost(): Promise<void> {
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root,
resolveLaunchArgs: () => [],
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DeleteNestedWorktreesDialog } from './DeleteNestedWorktreesDialog'
import { toast } from 'sonner'
const mocks = vi.hoisted(() => ({
preview: vi.fn(),
@@ -25,6 +26,7 @@ vi.mock('@/store/selectors', () => ({ getWorktreeOnHostFromState: () => null }))
vi.mock('./active-worktree-focus-after-delete', () => ({
prepareActiveWorktreeFocusAfterDelete: () => mocks.commitFocus
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
const target = { id: 'repo::/workspaces/parent', executionHostId: 'local' as const }
const plan = [
@@ -91,22 +93,66 @@ describe('nested deletion confirmation', () => {
expect(onDeleted).toHaveBeenCalledOnce()
})
it('refreshes partial deletions and requires a fresh review after failure', async () => {
mocks.remove.mockResolvedValue({ ok: false, error: 'Child archive hook failed' })
it('closes immediately while deletion is pending and prevents a second submission', async () => {
let finish: (value: { ok: boolean }) => void = () => {}
mocks.remove.mockReturnValue(
new Promise<{ ok: boolean }>((resolve) => {
finish = resolve
})
)
const dismissToast = vi.fn()
const onDeleted = vi.fn()
render(
<DeleteNestedWorktreesDialog target={target} worktreeName="parent" dismissToast={vi.fn()} />
<DeleteNestedWorktreesDialog
target={target}
worktreeName="parent"
dismissToast={dismissToast}
onDeleted={onDeleted}
/>
)
await review()
fireEvent.click(screen.getByRole('button', { name: 'Delete all listed worktrees' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Child archive hook failed')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Deleting…' })).toBeDisabled()
expect(mocks.refresh).not.toHaveBeenCalled()
expect(onDeleted).not.toHaveBeenCalled()
await act(async () => {
finish({ ok: true })
})
expect(mocks.remove).toHaveBeenCalledOnce()
expect(mocks.refresh).toHaveBeenCalledOnce()
expect(
screen.queryByRole('button', { name: 'Delete all listed worktrees' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Review again' }))
await vi.waitFor(() => expect(mocks.preview).toHaveBeenCalledTimes(2))
expect(onDeleted).toHaveBeenCalledOnce()
expect(dismissToast).toHaveBeenCalledOnce()
})
it.each(['refused', 'rejected'])(
'reports a %s deletion in a toast and requires a fresh review',
async (outcome) => {
if (outcome === 'refused') {
mocks.remove.mockResolvedValue({ ok: false, error: 'Child archive hook failed' })
} else {
mocks.remove.mockRejectedValue(new Error('Child archive hook failed'))
}
render(
<DeleteNestedWorktreesDialog target={target} worktreeName="parent" dismissToast={vi.fn()} />
)
await review()
fireEvent.click(screen.getByRole('button', { name: 'Delete all listed worktrees' }))
await vi.waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Failed to delete workspace', {
description: 'Child archive hook failed'
})
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(mocks.refresh).toHaveBeenCalledOnce()
expect(
screen.queryByRole('button', { name: 'Delete all listed worktrees' })
).not.toBeInTheDocument()
await review()
await vi.waitFor(() => expect(mocks.preview).toHaveBeenCalledTimes(2))
}
)
it('ignores a late preview after cancellation', async () => {
let finish: (value: typeof plan) => void = () => {}
mocks.preview.mockReturnValue(
@@ -65,6 +65,7 @@ export function DeleteNestedWorktreesDialog({
return
}
setDeleting(true)
setOpen(false)
setError(null)
const state = useAppStore.getState()
const repoId = getRepoIdFromWorktreeId(target.id)
@@ -82,15 +83,30 @@ export function DeleteNestedWorktreesDialog({
const result = await state.removeWorktree(target, true, { approvedNestedWorktrees: plan })
if (!result.ok) {
setPlan(null)
setError(result.error)
toast.error(
translate(
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
'Failed to delete workspace'
),
{
description: result.error
}
)
return
}
setOpen(false)
dismissToast()
onDeleted?.()
} catch (failure) {
setPlan(null)
setError(failure instanceof Error ? failure.message : String(failure))
toast.error(
translate(
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
'Failed to delete workspace'
),
{
description: failure instanceof Error ? failure.message : String(failure)
}
)
} finally {
try {
await useAppStore.getState().fetchAllWorktrees()
@@ -112,8 +128,17 @@ export function DeleteNestedWorktreesDialog({
return (
<>
<Button type="button" variant="destructive" size="sm" onClick={() => void review()}>
{translate('worktree.nestedRemoval.review', 'Delete with nested worktrees…')}
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleting}
onClick={() => void review()}
>
{deleting ? <LoaderCircle className="size-4 animate-spin" /> : null}
{deleting
? translate('worktree.nestedRemoval.deleting', 'Deleting…')
: translate('worktree.nestedRemoval.review', 'Delete with nested worktrees…')}
</Button>
<Dialog
open={open}