mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Offer always-delete option for preserved branches (#29505)
* Offer always-delete choice for preserved branches * Fix repository-wide lint blockers * Refresh CI contracts for runtime and draft workflows
This commit is contained in:
@@ -258,6 +258,23 @@ describe('registerSettingsHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes the preserved-branch deletion preference before returning', async () => {
|
||||
store.getSettings.mockReturnValue({ alwaysForceDeletePreservedBranches: 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, { alwaysForceDeletePreservedBranches: true })
|
||||
).rejects.toThrow('Disk full')
|
||||
expect(store.updateSettingsAndFlush).toHaveBeenCalledWith(
|
||||
{ alwaysForceDeletePreservedBranches: true },
|
||||
{ notifyListeners: true, originWebContentsId: 1 }
|
||||
)
|
||||
})
|
||||
|
||||
it('persists Active Server only through the dedicated preference channel', () => {
|
||||
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' })
|
||||
registerSettingsHandlers(store as never)
|
||||
|
||||
@@ -205,7 +205,8 @@ export function registerSettingsHandlers(
|
||||
originWebContentsId: event.sender.id
|
||||
}
|
||||
const result =
|
||||
'alwaysForceDeleteWorktrees' in sanitizedArgs
|
||||
'alwaysForceDeleteWorktrees' in sanitizedArgs ||
|
||||
'alwaysForceDeletePreservedBranches' in sanitizedArgs
|
||||
? await store.updateSettingsAndFlush(sanitizedArgs, updateOptions)
|
||||
: store.updateSettings(sanitizedArgs, updateOptions)
|
||||
const proxySettingsChanged =
|
||||
|
||||
@@ -20,6 +20,11 @@ export function WorktreeForceDeleteButton({
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
buttonRef,
|
||||
fullWidth = false,
|
||||
menuSide,
|
||||
moreActionsLabel,
|
||||
alwaysForceDeleteLabel,
|
||||
alwaysForceDeleteTooltip,
|
||||
children
|
||||
}: {
|
||||
toastId?: string
|
||||
@@ -29,6 +34,11 @@ export function WorktreeForceDeleteButton({
|
||||
size?: 'sm' | 'default'
|
||||
disabled?: boolean
|
||||
buttonRef?: Ref<HTMLButtonElement>
|
||||
fullWidth?: boolean
|
||||
menuSide?: 'top' | 'bottom'
|
||||
moreActionsLabel?: string
|
||||
alwaysForceDeleteLabel?: string
|
||||
alwaysForceDeleteTooltip?: string
|
||||
children?: ReactNode
|
||||
}): React.JSX.Element {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -62,6 +72,7 @@ export function WorktreeForceDeleteButton({
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<ButtonGroup
|
||||
className={fullWidth ? 'w-full' : undefined}
|
||||
onBlur={(event) => {
|
||||
if (
|
||||
event.relatedTarget instanceof HTMLElement &&
|
||||
@@ -77,6 +88,7 @@ export function WorktreeForceDeleteButton({
|
||||
variant="destructive"
|
||||
size={size}
|
||||
disabled={disabled || isSaving}
|
||||
className={fullWidth ? 'min-w-0 flex-1' : undefined}
|
||||
onClick={forceDelete}
|
||||
>
|
||||
{children ??
|
||||
@@ -89,7 +101,10 @@ export function WorktreeForceDeleteButton({
|
||||
variant="destructive"
|
||||
size={size === 'sm' ? 'icon-sm' : 'icon'}
|
||||
disabled={disabled || isSaving}
|
||||
aria-label={translate('workspaceDeletion.moreActions', 'More force delete options')}
|
||||
aria-label={
|
||||
moreActionsLabel ??
|
||||
translate('workspaceDeletion.moreActions', 'More force delete options')
|
||||
}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</Button>
|
||||
@@ -99,6 +114,7 @@ export function WorktreeForceDeleteButton({
|
||||
{onAlwaysForceDelete ? (
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side={menuSide}
|
||||
// Sonner restores toast focus on blur; the portaled menu owns its focus.
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
onBlur={(event) => event.stopPropagation()}
|
||||
@@ -111,14 +127,16 @@ export function WorktreeForceDeleteButton({
|
||||
void alwaysForceDelete()
|
||||
}}
|
||||
>
|
||||
{translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')}
|
||||
{alwaysForceDeleteLabel ??
|
||||
translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
{translate(
|
||||
'workspaceDeletion.alwaysForceDeleteTooltip',
|
||||
'Applies to all future workspace deletions.'
|
||||
)}
|
||||
{alwaysForceDeleteTooltip ??
|
||||
translate(
|
||||
'workspaceDeletion.alwaysForceDeleteTooltip',
|
||||
'Applies to all future workspace deletions.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -4,13 +4,15 @@ import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
import { showPreservedBranchToast } from './preserved-branch-toast'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
warning: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
dismiss: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -24,7 +26,7 @@ function renderToastBody(): HTMLElement {
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
act(() => {
|
||||
root.render(description)
|
||||
root.render(<TooltipProvider>{description}</TooltipProvider>)
|
||||
})
|
||||
return container
|
||||
}
|
||||
@@ -102,4 +104,55 @@ describe('showPreservedBranchToast', () => {
|
||||
expect.not.objectContaining({ duration: Infinity })
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the future branch choice before deleting the current branch', async () => {
|
||||
const savePreference = vi.fn().mockResolvedValue(undefined)
|
||||
const onForceDelete = vi.fn()
|
||||
showPreservedBranchToast(
|
||||
{ preservedBranch: { branchName: 'feature/test', head: 'abc123' } },
|
||||
undefined,
|
||||
onForceDelete,
|
||||
savePreference
|
||||
)
|
||||
const body = renderToastBody()
|
||||
const trigger = body.querySelector('button[aria-label="More branch delete options"]')
|
||||
expect(trigger).not.toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
})
|
||||
const option = document.querySelector('[role="menuitem"]')
|
||||
expect(option?.textContent).toBe('Always delete preserved branches')
|
||||
await act(async () => {
|
||||
option?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(savePreference).toHaveBeenCalledOnce()
|
||||
expect(onForceDelete).toHaveBeenCalledWith('feature/test', 'abc123')
|
||||
})
|
||||
|
||||
it('keeps the branch and toast when saving the future choice fails', async () => {
|
||||
const savePreference = vi.fn().mockRejectedValue(new Error('disk full'))
|
||||
const onForceDelete = vi.fn()
|
||||
showPreservedBranchToast(
|
||||
{ preservedBranch: { branchName: 'feature/test', head: 'abc123' } },
|
||||
undefined,
|
||||
onForceDelete,
|
||||
savePreference
|
||||
)
|
||||
const body = renderToastBody()
|
||||
await act(async () => {
|
||||
body
|
||||
.querySelector('button[aria-label="More branch delete options"]')
|
||||
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
})
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector('[role="menuitem"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onForceDelete).not.toHaveBeenCalled()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
|
||||
import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { WorktreeForceDeleteButton } from './WorktreeForceDeleteButton'
|
||||
|
||||
type PreservedBranchWorktree = Pick<Worktree, 'displayName' | 'isMainWorktree'>
|
||||
|
||||
@@ -11,6 +11,7 @@ type PreservedBranchToastBodyProps = {
|
||||
description: string
|
||||
forceDeleteLabel: string | undefined
|
||||
onForceDelete: (() => void) | undefined
|
||||
onAlwaysForceDelete: (() => Promise<void>) | undefined
|
||||
}
|
||||
|
||||
function preservedBranchToastId(branchName: string, expectedHead: string | undefined): string {
|
||||
@@ -53,7 +54,8 @@ function getPreservedBranchDescription(
|
||||
function PreservedBranchToastBody({
|
||||
description,
|
||||
forceDeleteLabel,
|
||||
onForceDelete
|
||||
onForceDelete,
|
||||
onAlwaysForceDelete
|
||||
}: PreservedBranchToastBodyProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex w-[300px] max-w-[calc(100vw-96px)] flex-col gap-3">
|
||||
@@ -61,17 +63,28 @@ function PreservedBranchToastBody({
|
||||
{description}
|
||||
</p>
|
||||
{forceDeleteLabel && onForceDelete ? (
|
||||
<div className="flex min-w-0 overflow-hidden">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full min-w-0"
|
||||
onClick={onForceDelete}
|
||||
<div className="min-w-0">
|
||||
<WorktreeForceDeleteButton
|
||||
fullWidth
|
||||
menuSide="top"
|
||||
onForceDelete={onForceDelete}
|
||||
onAlwaysForceDelete={onAlwaysForceDelete}
|
||||
moreActionsLabel={translate(
|
||||
'workspaceDeletion.moreBranchActions',
|
||||
'More branch delete options'
|
||||
)}
|
||||
alwaysForceDeleteLabel={translate(
|
||||
'workspaceDeletion.alwaysForceDeleteBranches',
|
||||
'Always delete preserved branches'
|
||||
)}
|
||||
alwaysForceDeleteTooltip={translate(
|
||||
'workspaceDeletion.alwaysForceDeleteBranchesTooltip',
|
||||
'Applies to branches kept after future workspace deletions.'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="truncate">{forceDeleteLabel}</span>
|
||||
</Button>
|
||||
</WorktreeForceDeleteButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -81,7 +94,8 @@ function PreservedBranchToastBody({
|
||||
export function showPreservedBranchToast(
|
||||
result: RemoveWorktreeResult | undefined,
|
||||
worktree: PreservedBranchWorktree | undefined,
|
||||
onForceDelete: (branchName: string, expectedHead: string) => void
|
||||
onForceDelete: (branchName: string, expectedHead: string) => void,
|
||||
onAlwaysForceDelete?: () => Promise<void>
|
||||
): void {
|
||||
const preservedBranch = result?.preservedBranch
|
||||
const branch = preservedBranch?.branchName
|
||||
@@ -111,6 +125,7 @@ export function showPreservedBranchToast(
|
||||
description={description}
|
||||
forceDeleteLabel={forceDeleteLabel}
|
||||
onForceDelete={forceDelete}
|
||||
onAlwaysForceDelete={onAlwaysForceDelete}
|
||||
/>
|
||||
),
|
||||
dismissible: true,
|
||||
|
||||
@@ -19925,7 +19925,10 @@
|
||||
"preferenceSaveFailed": "Could not save deletion preference",
|
||||
"alwaysForceDelete": "Always force delete",
|
||||
"moreActions": "More force delete options",
|
||||
"alwaysForceDeleteTooltip": "Applies to all future workspace deletions."
|
||||
"alwaysForceDeleteTooltip": "Applies to all future workspace deletions.",
|
||||
"moreBranchActions": "More branch delete options",
|
||||
"alwaysForceDeleteBranches": "Always delete preserved branches",
|
||||
"alwaysForceDeleteBranchesTooltip": "Applies to branches kept after future workspace deletions."
|
||||
},
|
||||
"worktree": {
|
||||
"nestedRemoval": {
|
||||
|
||||
@@ -115,3 +115,90 @@ it('keeps forgetting a disconnected workspace metadata-only when the preference
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes a preserved branch with its saved head when the branch preference is enabled', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
|
||||
store.setState({
|
||||
settings: {
|
||||
...getDefaultSettings('/home/test'),
|
||||
alwaysForceDeletePreservedBranches: true
|
||||
},
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
mockApi.worktrees.remove.mockResolvedValueOnce({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'abc123' }
|
||||
})
|
||||
|
||||
expect(await store.getState().removeWorktree({ id: wt.id, executionHostId: null })).toEqual({
|
||||
ok: true
|
||||
})
|
||||
expect(mockApi.worktrees.forceDeletePreservedBranch).toHaveBeenCalledWith({
|
||||
worktreeId: wt.id,
|
||||
branchName: 'feature/test',
|
||||
expectedHead: 'abc123',
|
||||
hostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a recoverable branch when automatic deletion fails', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1' })
|
||||
store.setState({
|
||||
settings: {
|
||||
...getDefaultSettings('/home/test'),
|
||||
alwaysForceDeletePreservedBranches: true
|
||||
},
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
mockApi.worktrees.remove.mockResolvedValueOnce({
|
||||
preservedBranch: { branchName: 'feature/test', head: 'abc123' }
|
||||
})
|
||||
mockApi.worktrees.forceDeletePreservedBranch.mockRejectedValueOnce(new Error('branch moved'))
|
||||
|
||||
expect(await store.getState().removeWorktree({ id: wt.id, executionHostId: null })).toEqual({
|
||||
ok: true,
|
||||
preservedBranch: { branchName: 'feature/test', head: 'abc123', hostId: 'local' }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes automatic branch deletion through the owner runtime', 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'),
|
||||
alwaysForceDeletePreservedBranches: true
|
||||
},
|
||||
worktreesByRepo: { [wt.repoId]: [wt] }
|
||||
})
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }) => ({
|
||||
id: 'rpc-removal',
|
||||
ok: true,
|
||||
result:
|
||||
method === 'worktree.rm'
|
||||
? { preservedBranch: { branchName: 'feature/remote', head: 'abc123' } }
|
||||
: { deleted: true },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}))
|
||||
|
||||
expect(
|
||||
await store.getState().removeWorktree({ id: wt.id, executionHostId: 'runtime:env-1' })
|
||||
).toEqual({ ok: true })
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-1',
|
||||
method: 'worktree.forceDeleteBranch',
|
||||
params: expect.objectContaining({
|
||||
worktree: `id:${wt.id}`,
|
||||
branchName: 'feature/remote',
|
||||
expectedHead: 'abc123'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import type { WorktreeSliceGet } from '../listing/worktree-slice-types'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
|
||||
export async function completePreservedBranchCleanup({
|
||||
get,
|
||||
worktreeId,
|
||||
removalResult,
|
||||
worktreeBeforeRemoval,
|
||||
branchCleanupOptions,
|
||||
suppressToast
|
||||
}: {
|
||||
get: WorktreeSliceGet
|
||||
worktreeId: string
|
||||
removalResult: RemoveWorktreeResult | undefined
|
||||
worktreeBeforeRemoval: Pick<Worktree, 'displayName' | 'isMainWorktree'> | undefined
|
||||
branchCleanupOptions: NonNullable<Parameters<WorktreeSlice['forceDeletePreservedBranch']>[3]>
|
||||
suppressToast: boolean
|
||||
}): Promise<boolean> {
|
||||
const preservedBranch = removalResult?.preservedBranch
|
||||
if (!preservedBranch) {
|
||||
return false
|
||||
}
|
||||
const autoDeleted =
|
||||
preservedBranch.head && get().settings?.alwaysForceDeletePreservedBranches === true
|
||||
? (
|
||||
await get().forceDeletePreservedBranch(
|
||||
worktreeId,
|
||||
preservedBranch.branchName,
|
||||
preservedBranch.head,
|
||||
{ ...branchCleanupOptions, suppressToast: true }
|
||||
)
|
||||
).ok
|
||||
: false
|
||||
if (!autoDeleted && !suppressToast) {
|
||||
showPreservedBranchToast(
|
||||
removalResult,
|
||||
worktreeBeforeRemoval,
|
||||
(branch, expectedHead) => {
|
||||
void get().forceDeletePreservedBranch(
|
||||
worktreeId,
|
||||
branch,
|
||||
expectedHead,
|
||||
branchCleanupOptions
|
||||
)
|
||||
},
|
||||
() => get().updateSettingsOrThrow({ alwaysForceDeletePreservedBranches: true })
|
||||
)
|
||||
}
|
||||
return autoDeleted
|
||||
}
|
||||
+21
-11
@@ -7,6 +7,7 @@
|
||||
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types'
|
||||
import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import {
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
|
||||
import { purgeOrphanedRuntimeSshProjects } from './orphaned-runtime-ssh-project-purge'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
import { completePreservedBranchCleanup } from './complete-preserved-branch-cleanup'
|
||||
|
||||
import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup'
|
||||
import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target'
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
|
||||
export { prepareHostScopedRemovalCompletion, preservesSameIdRendererState }
|
||||
|
||||
type PreservedBranchWorktree = Parameters<typeof showPreservedBranchToast>[1]
|
||||
type PreservedBranchWorktree = Pick<Worktree, 'displayName' | 'isMainWorktree'> | undefined
|
||||
type RemoveWorktreeSliceResult = Awaited<ReturnType<WorktreeSlice['removeWorktree']>>
|
||||
|
||||
/** Route at the confirmed host when there is one, else the ordinary active-host route. */
|
||||
@@ -251,16 +252,25 @@ export async function completeSameIdHostScopedRemoval(args: {
|
||||
cleanup,
|
||||
target
|
||||
})
|
||||
if (!suppressPreservedBranchToast) {
|
||||
showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => {
|
||||
void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, {
|
||||
hostId: requiredExecutionHostId,
|
||||
...runtimeEnvironment
|
||||
})
|
||||
})
|
||||
}
|
||||
const branchCleanupOptions = { hostId: requiredExecutionHostId, ...runtimeEnvironment }
|
||||
const autoDeletedBranch = await completePreservedBranchCleanup({
|
||||
get,
|
||||
worktreeId,
|
||||
removalResult,
|
||||
worktreeBeforeRemoval,
|
||||
branchCleanupOptions,
|
||||
suppressToast: suppressPreservedBranchToast
|
||||
})
|
||||
return {
|
||||
ok: true as const,
|
||||
preservedBranch: { ...preservedBranch, hostId: requiredExecutionHostId, ...runtimeEnvironment }
|
||||
...(!autoDeletedBranch
|
||||
? {
|
||||
preservedBranch: {
|
||||
...preservedBranch,
|
||||
hostId: requiredExecutionHostId,
|
||||
...runtimeEnvironment
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-contro
|
||||
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
import { readIpcErrorDetail } from '@/lib/ipc-error'
|
||||
import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/archive-hook-removal-gate'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
import {
|
||||
resolveWorktreeOperationRouteResult,
|
||||
resolveWorktreeOperationRouteResultForHost
|
||||
@@ -42,6 +41,7 @@ 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'
|
||||
import { completePreservedBranchCleanup } from './complete-preserved-branch-cleanup'
|
||||
|
||||
export function createRemoveWorktree(
|
||||
set: WorktreeSliceSet,
|
||||
@@ -268,18 +268,22 @@ export function createRemoveWorktree(
|
||||
target
|
||||
})
|
||||
}
|
||||
if (preservedBranch && options?.suppressPreservedBranchToast !== true) {
|
||||
showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => {
|
||||
void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, {
|
||||
...(hostId ? { hostId } : {}),
|
||||
...(removalRoute?.runtimeEnvironmentId
|
||||
? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId }
|
||||
: {})
|
||||
})
|
||||
})
|
||||
const branchCleanupOptions = {
|
||||
...(hostId ? { hostId } : {}),
|
||||
...(removalRoute?.runtimeEnvironmentId
|
||||
? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId }
|
||||
: {})
|
||||
}
|
||||
const autoDeletedBranch = await completePreservedBranchCleanup({
|
||||
get,
|
||||
worktreeId,
|
||||
removalResult,
|
||||
worktreeBeforeRemoval,
|
||||
branchCleanupOptions,
|
||||
suppressToast: options?.suppressPreservedBranchToast === true
|
||||
})
|
||||
pruneHostedReviewLinkMutationGenerations([worktreeId])
|
||||
return preservedBranch && cleanup
|
||||
return preservedBranch && cleanup && !autoDeletedBranch
|
||||
? {
|
||||
ok: true as const,
|
||||
preservedBranch: {
|
||||
|
||||
@@ -203,6 +203,7 @@ export function buildDefaultSettings(args: {
|
||||
claudeAgentTeamsDefaultDisabledMigrated: true,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
alwaysForceDeleteWorktrees: false,
|
||||
alwaysForceDeletePreservedBranches: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
skipDeleteArtifactConfirm: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NativeChatGlobalSettings } from './native-chat-appearance-settings'
|
||||
import type { WorktreeDeletionPreferences as DeletePrefs } from './worktree-deletion-preferences'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import type { OrcaWorkspaceLayout } from './orca-workspace-layout'
|
||||
import type { GitHubProjectSettings } from './github/project-types'
|
||||
@@ -49,7 +50,9 @@ export type { WorktreeVisibilityDefaults } from './repo-types'
|
||||
/** MiniMax account region used to select the quota endpoint. */
|
||||
export type MiniMaxEndpoint = 'overseas' | 'cn'
|
||||
|
||||
export type GlobalSettings = NativeChatGlobalSettings & {
|
||||
type GlobalSettingsBase = NativeChatGlobalSettings & DeletePrefs
|
||||
|
||||
export type GlobalSettings = GlobalSettingsBase & {
|
||||
workspaceDir: string
|
||||
/** Host-owned defaults used when a repository has no explicit visibility override. */
|
||||
worktreeVisibilityDefaults?: WorktreeVisibilityDefaults
|
||||
@@ -366,10 +369,6 @@ export type GlobalSettings = NativeChatGlobalSettings & {
|
||||
devPluginPaths: string[]
|
||||
/** One-shot guard: start Claude Agent Teams hidden for existing profiles without overriding later opt-ins. */
|
||||
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. */
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type WorktreeDeletionPreferences = {
|
||||
/** Worktree deletion removes the working directory, so confirmation is on by default. */
|
||||
skipDeleteWorktreeConfirm: boolean
|
||||
/** Opt-in: workspace deletion discards changes and may waive terminal-stop verification. */
|
||||
alwaysForceDeleteWorktrees?: boolean
|
||||
/** Opt-in: delete preserved local branches after workspace removal. */
|
||||
alwaysForceDeletePreservedBranches?: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user