mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Add saved force-delete preference to workspace deletion menu (#26425)
* Give Windows Electron writer probes a larger test budget * Drain queued writer replies across two poll turns before timing out * Check writer shutdown after both reply-drain turns * Add inline always-force-delete preference to workspace recovery toast * Use split-button menu for saved force-delete preference * Await durable force-delete preference writes before deleting * Restore absent settings keys after failed durable writes * Place deletion translations beside worktree copy
This commit is contained in:
@@ -99,6 +99,7 @@ type SettingsChangedListener = (
|
||||
const store = {
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
updateSettingsAndFlush: vi.fn(),
|
||||
getGitHubCache: vi.fn(),
|
||||
setGitHubCache: vi.fn(),
|
||||
onSettingsChanged: vi.fn(() => () => {})
|
||||
@@ -107,6 +108,7 @@ const store = {
|
||||
describe('registerSettingsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handleMock.mockClear()
|
||||
store.updateSettingsAndFlush.mockReset()
|
||||
onMock.mockClear()
|
||||
applyAppIconMock.mockClear()
|
||||
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
|
||||
@@ -239,6 +241,23 @@ describe('registerSettingsHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates a failed durable force-delete preference write through settings:set', async () => {
|
||||
store.getSettings.mockReturnValue({ alwaysForceDeleteWorktrees: false })
|
||||
store.updateSettingsAndFlush.mockRejectedValue(new Error('Disk full'))
|
||||
registerSettingsHandlers(store as never)
|
||||
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1]
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error('Missing settings:set handler')
|
||||
}
|
||||
await expect(
|
||||
handler(settingsInvokeEvent, { alwaysForceDeleteWorktrees: true, editorWordWrap: false })
|
||||
).rejects.toThrow('Disk full')
|
||||
expect(store.updateSettingsAndFlush).toHaveBeenCalledWith(
|
||||
{ alwaysForceDeleteWorktrees: true, editorWordWrap: false },
|
||||
{ notifyListeners: true, originWebContentsId: 1 }
|
||||
)
|
||||
})
|
||||
|
||||
it('persists Active Server only through the dedicated preference channel', () => {
|
||||
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' })
|
||||
registerSettingsHandlers(store as never)
|
||||
|
||||
@@ -199,10 +199,14 @@ export function registerSettingsHandlers(
|
||||
// (e.g. blur after a no-op edit), and a `settings_changed` event for a
|
||||
// no-op flip would inflate the experimental-feature-adoption signal.
|
||||
const before = store.getSettings()
|
||||
const result = store.updateSettings(sanitizedArgs, {
|
||||
const updateOptions = {
|
||||
notifyListeners: true,
|
||||
originWebContentsId: event.sender.id
|
||||
})
|
||||
}
|
||||
const result =
|
||||
'alwaysForceDeleteWorktrees' in sanitizedArgs
|
||||
? await store.updateSettingsAndFlush(sanitizedArgs, updateOptions)
|
||||
: store.updateSettings(sanitizedArgs, updateOptions)
|
||||
const proxySettingsChanged =
|
||||
('httpProxyUrl' in sanitizedArgs && before.httpProxyUrl !== result.httpProxyUrl) ||
|
||||
('httpProxyBypassRules' in sanitizedArgs &&
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createWorkerMaintenanceFixture,
|
||||
maintenanceBarrier
|
||||
} from './profile-state-maintenance-fixture'
|
||||
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: () => ({ nth_repo_added: 2 })
|
||||
}))
|
||||
vi.mock('../../ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
it('waits for the SQLite write before returning or publishing the preference', async () => {
|
||||
const { store, authority, readState } = await createWorkerMaintenanceFixture()
|
||||
const started = maintenanceBarrier()
|
||||
const release = maintenanceBarrier()
|
||||
const write = authority.writeSerializedDomains.bind(authority)
|
||||
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => {
|
||||
started.resolve()
|
||||
await release.promise
|
||||
await write(domains)
|
||||
})
|
||||
const onChanged = vi.fn()
|
||||
store.onSettingsChanged(onChanged)
|
||||
let settled = false
|
||||
const pending = store.updateSettingsAndFlush(
|
||||
{ alwaysForceDeleteWorktrees: true },
|
||||
{ notifyListeners: true, originWebContentsId: 7 }
|
||||
)
|
||||
void pending.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await started.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
store.updateSettings({ theme: 'dark' })
|
||||
release.resolve()
|
||||
expect((await pending).theme).toBe('dark')
|
||||
expect(readState().settings.alwaysForceDeleteWorktrees).toBe(true)
|
||||
expect(onChanged).toHaveBeenCalledWith(
|
||||
{ alwaysForceDeleteWorktrees: true },
|
||||
expect.objectContaining({ alwaysForceDeleteWorktrees: true }),
|
||||
7
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a failed disk write and rolls back without publishing the preference', async () => {
|
||||
const { store, authority, readState } = await createWorkerMaintenanceFixture()
|
||||
const previous = store.getSettings().alwaysForceDeleteWorktrees
|
||||
vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full'))
|
||||
const onChanged = vi.fn()
|
||||
store.onSettingsChanged(onChanged)
|
||||
await expect(
|
||||
store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }, { notifyListeners: true })
|
||||
).rejects.toThrow('Disk full')
|
||||
expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous)
|
||||
expect(readState().settings.alwaysForceDeleteWorktrees).toBe(previous)
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the preference while retaining unrelated concurrent settings edits', async () => {
|
||||
const { store, authority } = await createWorkerMaintenanceFixture()
|
||||
const previous = store.getSettings().alwaysForceDeleteWorktrees
|
||||
const started = maintenanceBarrier()
|
||||
const release = maintenanceBarrier()
|
||||
vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => {
|
||||
started.resolve()
|
||||
await release.promise
|
||||
throw new Error('Disk full')
|
||||
})
|
||||
const pending = store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true })
|
||||
const rejected = expect(pending).rejects.toThrow('Disk full')
|
||||
await started.promise
|
||||
store.updateSettings({ theme: 'dark' })
|
||||
release.resolve()
|
||||
await rejected
|
||||
expect(store.getSettings()).toMatchObject({
|
||||
alwaysForceDeleteWorktrees: previous,
|
||||
theme: 'dark'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes previously absent optional keys after a failed preference payload write', async () => {
|
||||
const { store, authority } = await createWorkerMaintenanceFixture()
|
||||
delete store.getSettings().editorWordWrap
|
||||
const previous = store.getSettings().alwaysForceDeleteWorktrees
|
||||
vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full'))
|
||||
await expect(
|
||||
store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true, editorWordWrap: false })
|
||||
).rejects.toThrow('Disk full')
|
||||
expect(Object.hasOwn(store.getSettings(), 'editorWordWrap')).toBe(false)
|
||||
expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous)
|
||||
})
|
||||
@@ -25,6 +25,7 @@ type ProfilePreferencesRuntime = Pick<
|
||||
| 'githubCacheDirty'
|
||||
| 'githubCacheGeneration'
|
||||
| 'protectedSecrets'
|
||||
| 'runDurableMutation'
|
||||
| 'settingsChangeListeners'
|
||||
| 'state'
|
||||
| 'uiChangeListeners'
|
||||
@@ -74,6 +75,52 @@ export class ProfilePreferences {
|
||||
return updateSettingsOperation(getSettingsMutationOperations(this), updates, options)
|
||||
}
|
||||
|
||||
async updateSettingsAndFlush(
|
||||
updates: Partial<GlobalSettings>,
|
||||
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
|
||||
): Promise<GlobalSettings> {
|
||||
const { runtime } = this[profilePreferencesContext]
|
||||
let changedUpdates: Partial<GlobalSettings> = {}
|
||||
await runtime.runDurableMutation(() => {
|
||||
const previous = runtime.state.settings
|
||||
const next = this.updateSettings(updates)
|
||||
const previousEntries = new Map(Object.entries(previous))
|
||||
const updateKeys = new Set(Object.keys(updates))
|
||||
changedUpdates = Object.fromEntries(
|
||||
Object.entries(next).filter(
|
||||
([key, value]) => updateKeys.has(key) && !Object.is(previousEntries.get(key), value)
|
||||
)
|
||||
)
|
||||
return {
|
||||
value: undefined,
|
||||
rollback: () => {
|
||||
const currentEntries = new Map(Object.entries(runtime.state.settings))
|
||||
const nextEntries = new Map(Object.entries(next))
|
||||
const restoredUpdates = Object.fromEntries(
|
||||
Object.entries(previous).filter(
|
||||
([key]) =>
|
||||
updateKeys.has(key) && Object.is(currentEntries.get(key), nextEntries.get(key))
|
||||
)
|
||||
)
|
||||
const restoredSettings = { ...runtime.state.settings, ...restoredUpdates }
|
||||
for (const key of updateKeys) {
|
||||
if (
|
||||
!previousEntries.has(key) &&
|
||||
Object.is(currentEntries.get(key), nextEntries.get(key))
|
||||
) {
|
||||
Reflect.deleteProperty(restoredSettings, key)
|
||||
}
|
||||
}
|
||||
runtime.state.settings = restoredSettings
|
||||
}
|
||||
}
|
||||
})
|
||||
if (options.notifyListeners && Object.keys(changedUpdates).length > 0) {
|
||||
notifySettingsChanged(this, changedUpdates, options.originWebContentsId)
|
||||
}
|
||||
return this.getSettings()
|
||||
}
|
||||
|
||||
getUI(): PersistedState['ui'] {
|
||||
return getPersistedUI(
|
||||
this[profilePreferencesContext].runtime.state,
|
||||
|
||||
@@ -101,8 +101,8 @@ function App(): React.JSX.Element {
|
||||
<BrowserWebAuthnAccountDialog />
|
||||
</LinkRoutingPreferenceDialogProvider>
|
||||
</ConfirmationDialogProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
</TooltipProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
<SkillFreshnessNudge />
|
||||
<WorktreeBaseFallbackDialog />
|
||||
<PinnedTabCloseDialog />
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { ButtonGroup } from '../ui/button-group'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
export function WorktreeForceDeleteButton({
|
||||
toastId,
|
||||
onForceDelete,
|
||||
onAlwaysForceDelete
|
||||
}: {
|
||||
toastId: string
|
||||
onForceDelete: () => void
|
||||
onAlwaysForceDelete?: () => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const forceDelete = (): void => {
|
||||
toast.dismiss(toastId)
|
||||
onForceDelete()
|
||||
}
|
||||
const alwaysForceDelete = async (): Promise<void> => {
|
||||
if (!onAlwaysForceDelete) {
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onAlwaysForceDelete()
|
||||
} catch (error) {
|
||||
setIsSaving(false)
|
||||
toast.error(
|
||||
translate('workspaceDeletion.preferenceSaveFailed', 'Could not save deletion preference'),
|
||||
{ description: error instanceof Error ? error.message : String(error) }
|
||||
)
|
||||
return
|
||||
}
|
||||
forceDelete()
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<ButtonGroup
|
||||
onBlur={(event) => {
|
||||
if (
|
||||
event.relatedTarget instanceof HTMLElement &&
|
||||
event.relatedTarget.closest('[data-radix-menu-content]')
|
||||
) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={forceDelete}
|
||||
>
|
||||
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
|
||||
</Button>
|
||||
{onAlwaysForceDelete ? (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
disabled={isSaving}
|
||||
aria-label={translate('workspaceDeletion.moreActions', 'More force delete options')}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
{onAlwaysForceDelete ? (
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
// Sonner restores toast focus on blur; the portaled menu owns its focus.
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
onBlur={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
void alwaysForceDelete()
|
||||
}}
|
||||
>
|
||||
{translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
{translate(
|
||||
'workspaceDeletion.alwaysForceDeleteTooltip',
|
||||
'Applies to all future workspace deletions.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DropdownMenuContent>
|
||||
) : null}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, isValidElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
let root: Root | undefined
|
||||
|
||||
function renderRecovery(onAlwaysForceDelete: () => Promise<void>, canForceDelete = true) {
|
||||
const onForceDelete = vi.fn()
|
||||
const onOutsideBlur = vi.fn()
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'branch has changes',
|
||||
canForceDelete,
|
||||
forceDeleteReason: canForceDelete ? 'dirty' : null,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete,
|
||||
onAlwaysForceDelete,
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'feature/foo'
|
||||
})
|
||||
const description = vi
|
||||
.mocked(canForceDelete ? toast.info : toast.error)
|
||||
.mock.calls.at(-1)?.[1]?.description
|
||||
if (!isValidElement(description)) {
|
||||
throw new Error('Expected a rendered recovery toast')
|
||||
}
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() =>
|
||||
root?.render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
onOutsideBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
return { container, onForceDelete, onOutsideBlur }
|
||||
}
|
||||
|
||||
function click(container: HTMLElement, selector: string): void {
|
||||
const element = container.querySelector(selector)
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Missing ${selector}`)
|
||||
}
|
||||
element.click()
|
||||
}
|
||||
|
||||
async function selectAlwaysForceDelete(container: HTMLElement): Promise<void> {
|
||||
await act(async () => {
|
||||
const trigger = container.querySelector('button[aria-label="More force delete options"]')
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error('Missing dropdown trigger')
|
||||
}
|
||||
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
})
|
||||
await act(async () => click(document.body, '[role="menuitem"]'))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
root = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the main Force Delete button a one-time action', async () => {
|
||||
const savePreference = vi.fn().mockResolvedValue(undefined)
|
||||
const { container, onForceDelete } = renderRecovery(savePreference)
|
||||
expect(container.querySelector('button[aria-label="More force delete options"]')).not.toBeNull()
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull()
|
||||
|
||||
await act(async () => click(container, 'button[data-variant="destructive"]'))
|
||||
|
||||
expect(savePreference).not.toHaveBeenCalled()
|
||||
expect(onForceDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps portaled menu focus from triggering toast focus restoration', async () => {
|
||||
const { container, onOutsideBlur } = renderRecovery(vi.fn())
|
||||
const trigger = container.querySelector('button[aria-label="More force delete options"]')
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error('Missing dropdown trigger')
|
||||
}
|
||||
await act(async () => {
|
||||
trigger.focus()
|
||||
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
})
|
||||
const item = document.querySelector('[role="menuitem"]')
|
||||
if (!(item instanceof HTMLElement)) {
|
||||
throw new Error('Missing preference menu item')
|
||||
}
|
||||
await act(async () => item.focus())
|
||||
expect(onOutsideBlur).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[role="menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the preference to persist before force-deleting and dismissing', async () => {
|
||||
let finishSaving: (() => void) | undefined
|
||||
const savePreference = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishSaving = resolve
|
||||
})
|
||||
)
|
||||
const { container, onForceDelete } = renderRecovery(savePreference)
|
||||
await selectAlwaysForceDelete(container)
|
||||
|
||||
expect(savePreference).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
container.querySelector('button[data-variant="destructive"]')?.hasAttribute('disabled')
|
||||
).toBe(true)
|
||||
expect(onForceDelete).not.toHaveBeenCalled()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
await act(async () => finishSaving?.())
|
||||
expect(onForceDelete).toHaveBeenCalledOnce()
|
||||
expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:wt-1')
|
||||
})
|
||||
|
||||
it('keeps recovery available when saving the preference fails', async () => {
|
||||
const { container, onForceDelete } = renderRecovery(
|
||||
vi.fn().mockRejectedValue(new Error('Disk full'))
|
||||
)
|
||||
await selectAlwaysForceDelete(container)
|
||||
|
||||
expect(onForceDelete).not.toHaveBeenCalled()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith('Could not save deletion preference', {
|
||||
description: 'Disk full'
|
||||
})
|
||||
expect(
|
||||
container.querySelector('button[data-variant="destructive"]')?.hasAttribute('disabled')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not offer the preference for a failure that cannot be force-deleted', () => {
|
||||
const { container } = renderRecovery(vi.fn(), false)
|
||||
expect(container.querySelector('button[aria-label="More force delete options"]')).toBeNull()
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { Button } from '../ui/button'
|
||||
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DeleteNestedWorktreesDialog } from './DeleteNestedWorktreesDialog'
|
||||
import { WorktreeForceDeleteButton } from './WorktreeForceDeleteButton'
|
||||
import { isNestedWorktreeRemovalError } from '../../../../shared/worktree/nested-removal'
|
||||
import {
|
||||
isLockedWorktreeRemovalError,
|
||||
@@ -20,6 +21,7 @@ type DeleteWorktreeFailureToastOptions = {
|
||||
canWaiveArchiveHook?: boolean
|
||||
onViewChanges: () => void
|
||||
onForceDelete: () => void
|
||||
onAlwaysForceDelete?: () => Promise<void>
|
||||
onDeleteAnyway: () => void
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
@@ -38,6 +40,7 @@ function DeleteWorktreeFailureToastBody({
|
||||
showViewChanges,
|
||||
onViewChanges,
|
||||
onForceDelete,
|
||||
onAlwaysForceDelete,
|
||||
onDeleteAnyway,
|
||||
toastId,
|
||||
nestedRemovalTarget,
|
||||
@@ -50,6 +53,7 @@ function DeleteWorktreeFailureToastBody({
|
||||
showViewChanges: boolean
|
||||
onViewChanges: () => void
|
||||
onForceDelete: () => void
|
||||
onAlwaysForceDelete?: () => Promise<void>
|
||||
onDeleteAnyway: () => void
|
||||
toastId: string
|
||||
nestedRemovalTarget?: WorktreeRemovalTarget
|
||||
@@ -60,10 +64,6 @@ function DeleteWorktreeFailureToastBody({
|
||||
toast.dismiss(toastId)
|
||||
onViewChanges()
|
||||
}
|
||||
const forceDelete = (): void => {
|
||||
toast.dismiss(toastId)
|
||||
onForceDelete()
|
||||
}
|
||||
const deleteAnyway = (): void => {
|
||||
toast.dismiss(toastId)
|
||||
onDeleteAnyway()
|
||||
@@ -89,9 +89,11 @@ function DeleteWorktreeFailureToastBody({
|
||||
/>
|
||||
) : null}
|
||||
{canForceDelete ? (
|
||||
<Button type="button" variant="destructive" size="sm" onClick={forceDelete}>
|
||||
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
|
||||
</Button>
|
||||
<WorktreeForceDeleteButton
|
||||
toastId={toastId}
|
||||
onForceDelete={onForceDelete}
|
||||
onAlwaysForceDelete={onAlwaysForceDelete}
|
||||
/>
|
||||
) : null}
|
||||
{canWaiveArchiveHook ? (
|
||||
<Button type="button" variant="destructive" size="sm" onClick={deleteAnyway}>
|
||||
@@ -115,6 +117,7 @@ export function showDeleteWorktreeFailureToast({
|
||||
canWaiveArchiveHook,
|
||||
onViewChanges,
|
||||
onForceDelete,
|
||||
onAlwaysForceDelete,
|
||||
onDeleteAnyway,
|
||||
worktreeId,
|
||||
worktreeName,
|
||||
@@ -146,6 +149,7 @@ export function showDeleteWorktreeFailureToast({
|
||||
showViewChanges={!isLockedWorktreeRemovalError(error) || hasKnownChanges === true}
|
||||
onViewChanges={onViewChanges}
|
||||
onForceDelete={onForceDelete}
|
||||
onAlwaysForceDelete={onAlwaysForceDelete}
|
||||
onDeleteAnyway={onDeleteAnyway}
|
||||
toastId={id}
|
||||
nestedRemovalTarget={nestedTarget}
|
||||
|
||||
@@ -60,6 +60,8 @@ export function runWorktreeDeleteWithToast(
|
||||
lockReason: state?.lockReason ?? null,
|
||||
hasKnownChanges,
|
||||
onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
|
||||
onAlwaysForceDelete: () =>
|
||||
useAppStore.getState().updateSettingsOrThrow({ alwaysForceDeleteWorktrees: true }),
|
||||
// Why (#19334): re-runs the archive hook and waives the failure this time, so the waiver
|
||||
// is an informed choice made after reading the refusal -- not something `force` implied.
|
||||
onDeleteAnyway: () =>
|
||||
|
||||
@@ -19475,6 +19475,12 @@
|
||||
"fileSearch": {
|
||||
"failed": "Search failed. Try again."
|
||||
},
|
||||
"workspaceDeletion": {
|
||||
"preferenceSaveFailed": "Could not save deletion preference",
|
||||
"alwaysForceDelete": "Always force delete",
|
||||
"moreActions": "More force delete options",
|
||||
"alwaysForceDeleteTooltip": "Applies to all future workspace deletions."
|
||||
},
|
||||
"worktree": {
|
||||
"nestedRemoval": {
|
||||
"refreshFailed": "Could not refresh the workspace list",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { makeWorktree } from './worktrees-slice-test-fixtures'
|
||||
import {
|
||||
createTestStore,
|
||||
mockApi,
|
||||
resetRemoteRuntimeMocks,
|
||||
resetWorktreeSliceModuleMemory,
|
||||
runtimeEnvironmentCall
|
||||
} from './worktrees-slice-test-harness'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetWorktreeSliceModuleMemory()
|
||||
resetRemoteRuntimeMocks()
|
||||
mockApi.worktrees.remove.mockResolvedValue({})
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-rm',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([undefined, false, true])(
|
||||
'uses the saved force-delete preference for local deletion: %s',
|
||||
async (enabled) => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
|
||||
store.setState({
|
||||
settings: { ...getDefaultSettings('/home/test'), alwaysForceDeleteWorktrees: enabled },
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.getState().removeWorktree({ id: wt.id, executionHostId: null }, false)
|
||||
).toEqual({ ok: true })
|
||||
expect(mockApi.worktrees.remove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: wt.id,
|
||||
force: enabled === true,
|
||||
allowUnverifiedPtyStop: enabled === true,
|
||||
allowFailedArchiveHook: false
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('applies the preference through the existing remote removal flags', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
hostId: 'runtime:env-1',
|
||||
runtimeOwnerEnvironmentId: 'env-1'
|
||||
})
|
||||
store.setState({
|
||||
settings: {
|
||||
...getDefaultSettings('/home/test'),
|
||||
activeRuntimeEnvironmentId: 'different-focused-hub',
|
||||
alwaysForceDeleteWorktrees: true
|
||||
},
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.getState().removeWorktree({ id: wt.id, executionHostId: 'runtime:env-1' }, false)
|
||||
).toEqual({ ok: true })
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-1',
|
||||
method: 'worktree.rm',
|
||||
params: expect.objectContaining({
|
||||
worktree: `id:${wt.id}`,
|
||||
force: true,
|
||||
allowUnverifiedPtyStop: true,
|
||||
runHooks: true
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports a failed forced removal instead of treating the workspace as deleted', async () => {
|
||||
mockApi.worktrees.remove.mockRejectedValue(new Error('Workspace is locked'))
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
|
||||
store.setState({
|
||||
settings: { ...getDefaultSettings('/home/test'), alwaysForceDeleteWorktrees: true },
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.getState().removeWorktree({ id: wt.id, executionHostId: null }, false)
|
||||
).toEqual({ ok: false, error: 'Workspace is locked' })
|
||||
expect(store.getState().worktreesByRepo[wt.repoId]).toEqual([wt])
|
||||
})
|
||||
|
||||
it('keeps forgetting a disconnected workspace metadata-only when the preference is enabled', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', hostId: 'ssh:offline' })
|
||||
store.setState({
|
||||
settings: { ...getDefaultSettings('/home/test'), alwaysForceDeleteWorktrees: true },
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.getState().removeWorktree({ id: wt.id, executionHostId: 'ssh:offline' }, false, {
|
||||
mode: 'forget-local'
|
||||
})
|
||||
).toEqual({ ok: true })
|
||||
expect(mockApi.worktrees.forgetLocal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: wt.id })
|
||||
)
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -42,6 +42,7 @@ import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-
|
||||
import { dispatchWorktreeRemoval } from './dispatch-worktree-removal'
|
||||
import { tearDownRemovedWorktreeRendererState } from './removed-worktree-renderer-teardown'
|
||||
import { captureWorktreeStateBeforeRemoval } from './worktree-state-before-removal'
|
||||
import { resolveWorktreeRemovalPreference } from './worktree-removal-preference'
|
||||
|
||||
export function createRemoveWorktree(
|
||||
set: WorktreeSliceSet,
|
||||
@@ -49,7 +50,7 @@ export function createRemoveWorktree(
|
||||
): WorktreeSlice['removeWorktree'] {
|
||||
return async (removalTarget, force, options) => {
|
||||
const worktreeId = removalTarget.id
|
||||
const forgetLocalOnly = options?.mode === 'forget-local'
|
||||
const policy = resolveWorktreeRemovalPreference(get().settings, force, options)
|
||||
// Why (STA-4343): this is the ONE chokepoint every delete entry point shares,
|
||||
// so host qualification is enforced here rather than at any single caller — a
|
||||
// guard bolted onto the sidebar would drift from the cleanup dialog's. The
|
||||
@@ -60,7 +61,7 @@ export function createRemoveWorktree(
|
||||
get,
|
||||
worktreeId,
|
||||
requiredExecutionHostId,
|
||||
forgetLocalOnly,
|
||||
policy.forgetLocalOnly,
|
||||
options?.ignoreWorkspaceCleanupScanSurvivors === true
|
||||
)
|
||||
if (!start.ok) {
|
||||
@@ -94,7 +95,7 @@ export function createRemoveWorktree(
|
||||
try {
|
||||
// Why: forget-local touches no remote, so there's no archive hook to run or trust prompt needed.
|
||||
// Why `get`: same-repo local deletes start together, and one approval must cover the rest.
|
||||
const skipArchive = forgetLocalOnly
|
||||
const skipArchive = policy.forgetLocalOnly
|
||||
? true
|
||||
: (await ensureHooksConfirmed(
|
||||
get,
|
||||
@@ -110,7 +111,7 @@ export function createRemoveWorktree(
|
||||
requiredExecutionHostId
|
||||
)
|
||||
const beforeRemoval = captureWorktreeStateBeforeRemoval(get(), worktreeId, hostId)
|
||||
if (!forgetLocalOnly) {
|
||||
if (!policy.forgetLocalOnly) {
|
||||
removalGenerationGuard?.assertCurrent()
|
||||
}
|
||||
// Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable.
|
||||
@@ -121,28 +122,28 @@ export function createRemoveWorktree(
|
||||
? { ...get().settings, activeRuntimeEnvironmentId: null }
|
||||
: { activeRuntimeEnvironmentId: null }
|
||||
)
|
||||
const unprovableRemoteRouting = forgetLocalOnly
|
||||
const unprovableRemoteRouting = policy.forgetLocalOnly
|
||||
? null
|
||||
: refuseUnprovableRemoteHostRouting(get, worktreeId, target.kind)
|
||||
if (unprovableRemoteRouting) {
|
||||
throw new Error(unprovableRemoteRouting)
|
||||
}
|
||||
let removalResult: RemoveWorktreeResult
|
||||
let snapshotPruneHandledByLocalMain = forgetLocalOnly || target.kind === 'local'
|
||||
let snapshotPruneHandledByLocalMain = policy.forgetLocalOnly || target.kind === 'local'
|
||||
try {
|
||||
removalResult = await dispatchWorktreeRemoval({
|
||||
worktreeId,
|
||||
hostId,
|
||||
force,
|
||||
force: policy.force,
|
||||
skipArchive,
|
||||
get,
|
||||
target,
|
||||
options,
|
||||
options: policy.options,
|
||||
assertCurrent: () => removalGenerationGuard?.assertCurrent()
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
!forgetLocalOnly &&
|
||||
!policy.forgetLocalOnly &&
|
||||
target.kind !== 'local' &&
|
||||
(isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error))
|
||||
) {
|
||||
@@ -305,8 +306,8 @@ export function createRemoveWorktree(
|
||||
const error = readIpcErrorDetail(err) ?? (err instanceof Error ? err.message : String(err))
|
||||
const forceDeleteReason = classifyWorktreeForceDeleteReason(
|
||||
error,
|
||||
force,
|
||||
options?.allowUnverifiedPtyStop === true
|
||||
policy.force,
|
||||
policy.options?.allowUnverifiedPtyStop === true
|
||||
)
|
||||
const locked = isLockedWorktreeRemovalError(error)
|
||||
// Why (#19334): the refusal is the only failure a retry can clear by waiving rather than by
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
|
||||
import type { RemoveWorktreeOptions } from '../../worktree-removal-options'
|
||||
|
||||
export function resolveWorktreeRemovalPreference(
|
||||
settings: GlobalSettings | null,
|
||||
force: boolean | undefined,
|
||||
options: RemoveWorktreeOptions | undefined
|
||||
): {
|
||||
forgetLocalOnly: boolean
|
||||
force: boolean | undefined
|
||||
options: RemoveWorktreeOptions | undefined
|
||||
} {
|
||||
const forgetLocalOnly = options?.mode === 'forget-local'
|
||||
const alwaysForceDelete = !forgetLocalOnly && settings?.alwaysForceDeleteWorktrees === true
|
||||
return {
|
||||
forgetLocalOnly,
|
||||
force: alwaysForceDelete || force,
|
||||
options: alwaysForceDelete ? { ...options, allowUnverifiedPtyStop: true } : options
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,7 @@ export function buildDefaultSettings(args: {
|
||||
devPluginPaths: [],
|
||||
claudeAgentTeamsDefaultDisabledMigrated: true,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
alwaysForceDeleteWorktrees: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
skipDeleteArtifactConfirm: false,
|
||||
|
||||
@@ -361,6 +361,8 @@ export type GlobalSettings = NativeChatGlobalSettings & {
|
||||
claudeAgentTeamsDefaultDisabledMigrated?: boolean
|
||||
/** Why: worktree deletion is destructive (rm -rf of the working dir), so confirm by default. */
|
||||
skipDeleteWorktreeConfirm: boolean
|
||||
/** Opt-in: workspace deletion discards changes and may waive terminal-stop verification. */
|
||||
alwaysForceDeleteWorktrees?: boolean
|
||||
/** Why: closing a terminal with child processes kills foreground work; keep this skip separate from other confirmations. */
|
||||
skipCloseTerminalWithRunningProcessConfirm: boolean
|
||||
/** Why: deleting an automation also deletes its run history; keep this skip separate from worktree deletion. */
|
||||
|
||||
Reference in New Issue
Block a user