From c0fc6b0c689c899c301d328a73f7056aac4cde3f Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 22:42:14 -0700 Subject: [PATCH 1/8] Give Windows Electron writer probes a larger test budget --- .../profile-state/profile-state-writer-stall.electron.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts index 58175d0c0459..520d9a1f2efb 100644 --- a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts @@ -9,7 +9,9 @@ import { resolveElectronProbeLaunch } from '../../browser/electron-probe-display const root = mkdtempSync(join(tmpdir(), 'orca-writer-electron-')) const entry = join(root, 'main.cjs') -const timeoutMs = Number(process.env.ORCA_PROFILE_STALL_TIMEOUT_MS ?? 2_000) +// Windows CI worker startup competes with parallel Electron probes. +const defaultTimeoutMs = process.platform === 'win32' ? 10_000 : 2_000 +const timeoutMs = Number(process.env.ORCA_PROFILE_STALL_TIMEOUT_MS ?? defaultTimeoutMs) const processTimeoutMs = timeoutMs * 6 + 30_000 beforeAll(async () => { From 3d54b6edd0d46ebaf8d5852f0b7d878ce22fd630 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 22:59:59 -0700 Subject: [PATCH 2/8] Drain queued writer replies across two poll turns before timing out --- .../profile-state-writer-deadline.test.ts | 37 +++++++++++++------ .../profile-state-writer-deadline.ts | 14 ++++--- .../profile-state-writer-electron-fixture.ts | 13 ++++++- ...rofile-state-writer-stall.electron.test.ts | 4 +- 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts b/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts index 3a60c019717c..95bd251d6cbb 100644 --- a/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-deadline.test.ts @@ -13,6 +13,11 @@ import { const TIMEOUT_MS = 30_000 +async function drainTimeoutCheck(): Promise { + await waitForPoll() + await waitForPoll() +} + afterEach(() => { vi.useRealTimers() publishSystemResume() @@ -48,6 +53,16 @@ function harness() { } describe('profile state writer deadline', () => { + it('lets a queued reply clear the deadline after the first poll', async () => { + const { deadline, onTimeout, unsubscribe, run } = harness() + run(TIMEOUT_MS) + setImmediate(() => deadline.clear()) + await drainTimeoutCheck() + expect(onTimeout).not.toHaveBeenCalled() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + it('times out a request after allowing queued replies to drain', async () => { const { onTimeout, onGrace, unsubscribe, run } = harness() run(TIMEOUT_MS - 1) @@ -55,7 +70,7 @@ describe('profile state writer deadline', () => { run(1) expect(onGrace).not.toHaveBeenCalled() expect(onTimeout).not.toHaveBeenCalled() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ overdueMs: 0, graces: 0, powerState: 'awake' }) ) @@ -74,7 +89,7 @@ describe('profile state writer deadline', () => { run(TIMEOUT_MS - 1) expect(onTimeout).not.toHaveBeenCalled() run(1) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ overdueMs: 0, graces: 1 }) ) @@ -85,7 +100,7 @@ describe('profile state writer deadline', () => { stall(PROFILE_STATE_WRITER_OVERDUE_GRACE_MS - 1) run(TIMEOUT_MS) expect(onGrace).not.toHaveBeenCalled() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledOnce() }) @@ -99,7 +114,7 @@ describe('profile state writer deadline', () => { stall(60 * 60_000) run(TIMEOUT_MS) expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ graces: PROFILE_STATE_WRITER_MAX_GRACES }) ) @@ -114,7 +129,7 @@ describe('profile state writer deadline', () => { run(TIMEOUT_MS - 1) expect(onTimeout).not.toHaveBeenCalled() run(1) - await waitForPoll() + await drainTimeoutCheck() expect(onGrace).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ graces: 1 })) expect(onTimeout).toHaveBeenCalledOnce() }) @@ -129,7 +144,7 @@ describe('profile state writer deadline', () => { expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) expect(onTimeout).not.toHaveBeenCalled() run(TIMEOUT_MS) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ powerState: 'suspended' }) ) @@ -148,7 +163,7 @@ describe('profile state writer deadline', () => { power().onResume() } run(1) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ graces: PROFILE_STATE_WRITER_MAX_GRACES }) ) @@ -170,7 +185,7 @@ describe('profile state writer deadline', () => { run(TIMEOUT_MS) // An exhausted resume must not cancel the final queued timeout check. power().onResume() - await waitForPoll() + await drainTimeoutCheck() expect(onGrace).toHaveBeenCalledTimes(PROFILE_STATE_WRITER_MAX_GRACES) expect(onTimeout).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) @@ -183,7 +198,7 @@ describe('profile state writer deadline', () => { const { deadline, onTimeout, unsubscribe, run } = harness() run(TIMEOUT_MS) deadline.clear() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).not.toHaveBeenCalled() expect(unsubscribe).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) @@ -193,10 +208,10 @@ describe('profile state writer deadline', () => { const { onTimeout, run, power } = harness() run(TIMEOUT_MS) power().onResume() - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).not.toHaveBeenCalled() run(TIMEOUT_MS) - await waitForPoll() + await drainTimeoutCheck() expect(onTimeout).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/persistence/profile-state/profile-state-writer-deadline.ts b/src/main/persistence/profile-state/profile-state-writer-deadline.ts index fc17faea8b7d..9bc2db835004 100644 --- a/src/main/persistence/profile-state/profile-state-writer-deadline.ts +++ b/src/main/persistence/profile-state/profile-state-writer-deadline.ts @@ -97,13 +97,15 @@ export function createProfileStateWriterDeadline( ) { return } - // Even an on-time timer can run before a reply already queued by the worker. + // Windows can deliver a queued worker reply after the first poll following a stall. timeoutCheck = setImmediate(() => { - timeoutCheck = undefined - if (active) { - clear() - onTimeout(expiry) - } + timeoutCheck = setImmediate(() => { + timeoutCheck = undefined + if (active) { + clear() + onTimeout(expiry) + } + }) }) } // Subscription replays the current state synchronously; only later resumes re-arm. diff --git a/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts b/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts index e67832e8fc7e..12f946c27bb2 100644 --- a/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts +++ b/src/main/persistence/profile-state/profile-state-writer-electron-fixture.ts @@ -38,19 +38,29 @@ async function run(): Promise { const counters = new SharedArrayBuffer(8) const counts = new Int32Array(counters) const failures: string[] = [] + let phase = 'initialize' const initialization = { databasePath, profileId, revision: 0, counters } const client = new ProfileStateWriteWorkerClient(initialization, { workerPath: join(root, 'observed-worker.cjs'), timeoutMs, - onFailure: (error) => failures.push(error.message) + onFailure: (error) => { + failures.push(error.message) + console.error('[writer-fixture] failure', { + phase, + queuedReplies: Atomics.load(counts, 0), + workerStarts: Atomics.load(counts, 1) + }) + } }) const write = (marker: string) => client.writeSerializedDomains([{ domain: 'ui', payload: JSON.stringify({ marker }) }]) const revisions: number[] = [] try { await client.ready + phase = 'before' assert.equal(await write('before'), 1) for (let cycle = 0; cycle < 4; cycle += 1) { + phase = `stall-${cycle}` let pending: Promise | undefined await new Promise((resolve, reject) => { // Dispatch from check so the overdue timer runs before the next poll. @@ -76,6 +86,7 @@ async function run(): Promise { } revisions.push(await pending) } + phase = 'after' assert.equal(await write('after'), 6) } finally { await client.close() diff --git a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts index 520d9a1f2efb..58175d0c0459 100644 --- a/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts @@ -9,9 +9,7 @@ import { resolveElectronProbeLaunch } from '../../browser/electron-probe-display const root = mkdtempSync(join(tmpdir(), 'orca-writer-electron-')) const entry = join(root, 'main.cjs') -// Windows CI worker startup competes with parallel Electron probes. -const defaultTimeoutMs = process.platform === 'win32' ? 10_000 : 2_000 -const timeoutMs = Number(process.env.ORCA_PROFILE_STALL_TIMEOUT_MS ?? defaultTimeoutMs) +const timeoutMs = Number(process.env.ORCA_PROFILE_STALL_TIMEOUT_MS ?? 2_000) const processTimeoutMs = timeoutMs * 6 + 30_000 beforeAll(async () => { From 9c4fe704e338923234f86c941911197e163a49ed Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 23:04:26 -0700 Subject: [PATCH 3/8] Check writer shutdown after both reply-drain turns --- .../persistence/profile-state/profile-state-writer-close.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/persistence/profile-state/profile-state-writer-close.test.ts b/src/main/persistence/profile-state/profile-state-writer-close.test.ts index a082a1cb4ade..cf8b6a442585 100644 --- a/src/main/persistence/profile-state/profile-state-writer-close.test.ts +++ b/src/main/persistence/profile-state/profile-state-writer-close.test.ts @@ -68,6 +68,7 @@ it.each([true, false])( await waitForPoll() vi.advanceTimersByTime(TIMEOUT_MS) await waitForPoll() + await waitForPoll() expect(worker.terminate).toHaveBeenCalledOnce() expect(settled).toBe(false) worker.emit('exit', 1) From d2f0539c2e687ddb34b05d7991447dff79899e2a Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 21:07:52 -0700 Subject: [PATCH 4/8] Add inline always-force-delete preference to workspace recovery toast --- .../AlwaysForceDeleteWorkspacesSetting.tsx | 38 ++++++ .../GeneralWorkspaceSettingsSection.tsx | 3 + .../src/components/settings/general-search.ts | 16 +++ ...-worktree-always-force-preference.test.tsx | 109 ++++++++++++++++ .../sidebar/delete-worktree-failure-toast.tsx | 56 ++++++++- .../sidebar/run-worktree-delete-with-toast.ts | 2 + src/renderer/src/i18n/locales/en.json | 7 ++ .../worktrees-always-force-delete.test.ts | 117 ++++++++++++++++++ .../worktrees/teardown/remove-worktree.ts | 23 ++-- .../teardown/worktree-removal-preference.ts | 20 +++ src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 2 + 12 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx create mode 100644 src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx create mode 100644 src/renderer/src/store/slices/worktrees-always-force-delete.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/worktree-removal-preference.ts diff --git a/src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx b/src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx new file mode 100644 index 000000000000..91d45bbdc45a --- /dev/null +++ b/src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx @@ -0,0 +1,38 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' + +export function AlwaysForceDeleteWorkspacesSetting({ + settings, + updateSettings +}: { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +}): React.JSX.Element { + const title = translate('workspaceDeletion.settingTitle', 'Always Force Delete Workspaces') + const description = translate( + 'workspaceDeletion.settingDescription', + 'Discard uncommitted changes and delete even when terminal shutdown cannot be verified. Archive hook failures and locked worktrees still require attention.' + ) + return ( +
+ + + updateSettings({ + alwaysForceDeleteWorktrees: settings.alwaysForceDeleteWorktrees !== true + }) + } + /> + +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index 41f803040c17..82ea492dd88b 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -6,6 +6,7 @@ import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormContr import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' import { translate } from '@/i18n/i18n' import { GlobalWorktreeVisibilitySourcesSetting } from './GlobalWorktreeVisibilitySourcesSetting' +import { AlwaysForceDeleteWorkspacesSetting } from './AlwaysForceDeleteWorkspacesSetting' import { GLOBAL_WORKTREE_VISIBILITY_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' type GeneralWorkspaceSettingsSectionProps = { @@ -135,6 +136,8 @@ export function GeneralWorkspaceSettingsSection({ + +
[ ...translateSearchKeyword('auto.components.settings.general.search.0efc9d96ad', 'prompt') ] }, + { + title: translate('workspaceDeletion.settingTitle', 'Always Force Delete Workspaces'), + description: translate( + 'workspaceDeletion.settingDescription', + 'Discard uncommitted changes and delete even when terminal shutdown cannot be verified. Archive hook failures and locked worktrees still require attention.' + ), + keywords: searchKeywords([ + 'delete', + 'force', + 'worktree', + 'workspace', + 'always', + 'changes', + 'terminal' + ]) + }, { title: translate( 'auto.components.settings.general.search.d0a65b27fd', diff --git a/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx b/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx new file mode 100644 index 000000000000..1aaf34e2a09f --- /dev/null +++ b/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx @@ -0,0 +1,109 @@ +// @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 { 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, canForceDelete = true) { + const onForceDelete = 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(description)) + return { container, onForceDelete } +} + +function click(container: HTMLElement, selector: string): void { + const element = container.querySelector(selector) + if (!(element instanceof HTMLElement)) { + throw new Error(`Missing ${selector}`) + } + element.click() +} + +afterEach(() => { + act(() => root?.unmount()) + root = undefined + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +it('keeps Force Delete a one-time action unless the checkbox is selected', async () => { + const savePreference = vi.fn().mockResolvedValue(undefined) + const { container, onForceDelete } = renderRecovery(savePreference) + expect(container.querySelector('[role="checkbox"]')?.getAttribute('aria-checked')).toBe('false') + + await act(async () => click(container, 'button[data-variant="destructive"]')) + + expect(savePreference).not.toHaveBeenCalled() + expect(onForceDelete).toHaveBeenCalledOnce() +}) + +it('waits for the preference to persist before force-deleting and dismissing', async () => { + let finishSaving: (() => void) | undefined + const savePreference = vi.fn( + () => + new Promise((resolve) => { + finishSaving = resolve + }) + ) + const { container, onForceDelete } = renderRecovery(savePreference) + await act(async () => click(container, '[role="checkbox"]')) + expect(savePreference).not.toHaveBeenCalled() + await act(async () => click(container, 'button[data-variant="destructive"]')) + + 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 act(async () => click(container, '[role="checkbox"]')) + await act(async () => click(container, 'button[data-variant="destructive"]')) + + 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('[role="checkbox"]')).toBeNull() +}) diff --git a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx index 5f6e93444fa7..7705ae60acdb 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx @@ -1,5 +1,8 @@ import { toast } from 'sonner' +import { useId, useState } from 'react' import { Button } from '../ui/button' +import { Checkbox } from '../ui/checkbox' +import { Label } from '../ui/label' import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' import { translate } from '@/i18n/i18n' import { DeleteNestedWorktreesDialog } from './DeleteNestedWorktreesDialog' @@ -20,6 +23,7 @@ type DeleteWorktreeFailureToastOptions = { canWaiveArchiveHook?: boolean onViewChanges: () => void onForceDelete: () => void + onAlwaysForceDelete?: () => Promise onDeleteAnyway: () => void worktreeId: string worktreeName: string @@ -38,6 +42,7 @@ function DeleteWorktreeFailureToastBody({ showViewChanges, onViewChanges, onForceDelete, + onAlwaysForceDelete, onDeleteAnyway, toastId, nestedRemovalTarget, @@ -50,17 +55,34 @@ function DeleteWorktreeFailureToastBody({ showViewChanges: boolean onViewChanges: () => void onForceDelete: () => void + onAlwaysForceDelete?: () => Promise onDeleteAnyway: () => void toastId: string nestedRemovalTarget?: WorktreeRemovalTarget worktreeName: string onNestedDeleted?: () => void }): React.JSX.Element { + const preferenceId = useId() + const [alwaysForceDelete, setAlwaysForceDelete] = useState(false) + const [isSaving, setIsSaving] = useState(false) const viewChanges = (): void => { toast.dismiss(toastId) onViewChanges() } - const forceDelete = (): void => { + const forceDelete = async (): Promise => { + if (alwaysForceDelete && onAlwaysForceDelete) { + 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 + } + } toast.dismiss(toastId) onForceDelete() } @@ -74,6 +96,28 @@ function DeleteWorktreeFailureToastBody({ {description ? (

{description}

) : null} + {canForceDelete && onAlwaysForceDelete ? ( +
+
+ setAlwaysForceDelete(checked === true)} + aria-describedby={`${preferenceId}-description`} + /> + +
+

+ {translate( + 'workspaceDeletion.alwaysForceDeleteDescription', + 'Future deletions discard changes, even if terminal shutdown cannot be verified. Change this in Settings.' + )} +

+
+ ) : null}
{showViewChanges ? ( ) : null} @@ -115,6 +165,7 @@ export function showDeleteWorktreeFailureToast({ canWaiveArchiveHook, onViewChanges, onForceDelete, + onAlwaysForceDelete, onDeleteAnyway, worktreeId, worktreeName, @@ -146,6 +197,7 @@ export function showDeleteWorktreeFailureToast({ showViewChanges={!isLockedWorktreeRemovalError(error) || hasKnownChanges === true} onViewChanges={onViewChanges} onForceDelete={onForceDelete} + onAlwaysForceDelete={onAlwaysForceDelete} onDeleteAnyway={onDeleteAnyway} toastId={id} nestedRemovalTarget={nestedTarget} diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts index 68f8d0efea42..640ac5fc15aa 100644 --- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts +++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts @@ -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: () => diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9cc7c3d90488..eec71f672d0b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19591,5 +19591,12 @@ "updateRuntime": "Update the remote runtime to change workspace links", "updateRuntimeSafely": "Update the remote runtime to safely change workspace links" } + }, + "workspaceDeletion": { + "settingTitle": "Always Force Delete Workspaces", + "settingDescription": "Discard uncommitted changes and delete even when terminal shutdown cannot be verified. Archive hook failures and locked worktrees still require attention.", + "preferenceSaveFailed": "Could not save deletion preference", + "alwaysForceDelete": "Always force delete", + "alwaysForceDeleteDescription": "Future deletions discard changes, even if terminal shutdown cannot be verified. Change this in Settings." } } diff --git a/src/renderer/src/store/slices/worktrees-always-force-delete.test.ts b/src/renderer/src/store/slices/worktrees-always-force-delete.test.ts new file mode 100644 index 000000000000..ebe52c37af81 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees-always-force-delete.test.ts @@ -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() +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 626aeacc2bfa..1ad559bfe420 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -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 diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-removal-preference.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-removal-preference.ts new file mode 100644 index 000000000000..0e3bfe5af9fb --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-removal-preference.ts @@ -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 + } +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 3e8bb8abbca3..7c0bd6afe7c5 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -200,6 +200,7 @@ export function buildDefaultSettings(args: { devPluginPaths: [], claudeAgentTeamsDefaultDisabledMigrated: true, skipDeleteWorktreeConfirm: false, + alwaysForceDeleteWorktrees: false, skipCloseTerminalWithRunningProcessConfirm: false, skipDeleteAutomationConfirm: false, skipDeleteArtifactConfirm: false, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 41fa7e8afa5b..b310d9fca0b1 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -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. */ From 091ba258d1cf7de1742fe788167fbe95f200fe1f Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 7 Oct 2026 21:58:14 -0700 Subject: [PATCH 5/8] Use split-button menu for saved force-delete preference --- src/renderer/src/App.tsx | 2 +- .../AlwaysForceDeleteWorkspacesSetting.tsx | 38 ------ .../GeneralWorkspaceSettingsSection.tsx | 3 - .../src/components/settings/general-search.ts | 16 --- .../sidebar/WorktreeForceDeleteButton.tsx | 111 ++++++++++++++++++ ...-worktree-always-force-preference.test.tsx | 64 ++++++++-- .../sidebar/delete-worktree-failure-toast.tsx | 60 +--------- src/renderer/src/i18n/locales/en.json | 5 +- 8 files changed, 174 insertions(+), 125 deletions(-) delete mode 100644 src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeForceDeleteButton.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 892c445d100b..1ed1194a66bb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -101,8 +101,8 @@ function App(): React.JSX.Element { + - diff --git a/src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx b/src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx deleted file mode 100644 index 91d45bbdc45a..000000000000 --- a/src/renderer/src/components/settings/AlwaysForceDeleteWorkspacesSetting.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { translate } from '@/i18n/i18n' -import { SearchableSetting } from './SearchableSetting' -import { SettingsSwitchRow } from './SettingsFormControls' - -export function AlwaysForceDeleteWorkspacesSetting({ - settings, - updateSettings -}: { - settings: GlobalSettings - updateSettings: (updates: Partial) => void -}): React.JSX.Element { - const title = translate('workspaceDeletion.settingTitle', 'Always Force Delete Workspaces') - const description = translate( - 'workspaceDeletion.settingDescription', - 'Discard uncommitted changes and delete even when terminal shutdown cannot be verified. Archive hook failures and locked worktrees still require attention.' - ) - return ( -
- - - updateSettings({ - alwaysForceDeleteWorktrees: settings.alwaysForceDeleteWorktrees !== true - }) - } - /> - -
- ) -} diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index 82ea492dd88b..41f803040c17 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -6,7 +6,6 @@ import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormContr import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting' import { translate } from '@/i18n/i18n' import { GlobalWorktreeVisibilitySourcesSetting } from './GlobalWorktreeVisibilitySourcesSetting' -import { AlwaysForceDeleteWorkspacesSetting } from './AlwaysForceDeleteWorkspacesSetting' import { GLOBAL_WORKTREE_VISIBILITY_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' type GeneralWorkspaceSettingsSectionProps = { @@ -136,8 +135,6 @@ export function GeneralWorkspaceSettingsSection({
- -
[ ...translateSearchKeyword('auto.components.settings.general.search.0efc9d96ad', 'prompt') ] }, - { - title: translate('workspaceDeletion.settingTitle', 'Always Force Delete Workspaces'), - description: translate( - 'workspaceDeletion.settingDescription', - 'Discard uncommitted changes and delete even when terminal shutdown cannot be verified. Archive hook failures and locked worktrees still require attention.' - ), - keywords: searchKeywords([ - 'delete', - 'force', - 'worktree', - 'workspace', - 'always', - 'changes', - 'terminal' - ]) - }, { title: translate( 'auto.components.settings.general.search.d0a65b27fd', diff --git a/src/renderer/src/components/sidebar/WorktreeForceDeleteButton.tsx b/src/renderer/src/components/sidebar/WorktreeForceDeleteButton.tsx new file mode 100644 index 000000000000..259176381ee9 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeForceDeleteButton.tsx @@ -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 +}): React.JSX.Element { + const [isSaving, setIsSaving] = useState(false) + const forceDelete = (): void => { + toast.dismiss(toastId) + onForceDelete() + } + const alwaysForceDelete = async (): Promise => { + 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 ( + + { + if ( + event.relatedTarget instanceof HTMLElement && + event.relatedTarget.closest('[data-radix-menu-content]') + ) { + event.stopPropagation() + } + }} + > + + {onAlwaysForceDelete ? ( + + + + ) : null} + + {onAlwaysForceDelete ? ( + event.stopPropagation()} + onBlur={(event) => event.stopPropagation()} + > + + + { + void alwaysForceDelete() + }} + > + {translate('workspaceDeletion.alwaysForceDelete', 'Always force delete')} + + + + {translate( + 'workspaceDeletion.alwaysForceDeleteTooltip', + 'Applies to all future workspace deletions.' + )} + + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx b/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx index 1aaf34e2a09f..c372f5204c6d 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-always-force-preference.test.tsx @@ -4,6 +4,7 @@ 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() } })) @@ -12,6 +13,7 @@ let root: Root | undefined function renderRecovery(onAlwaysForceDelete: () => Promise, canForceDelete = true) { const onForceDelete = vi.fn() + const onOutsideBlur = vi.fn() showDeleteWorktreeFailureToast({ error: 'branch has changes', canForceDelete, @@ -32,8 +34,22 @@ function renderRecovery(onAlwaysForceDelete: () => Promise, canForceDelete const container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - act(() => root?.render(description)) - return { container, onForceDelete } + act(() => + root?.render( + +
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + onOutsideBlur() + } + }} + > + {description} +
+
+ ) + ) + return { container, onForceDelete, onOutsideBlur } } function click(container: HTMLElement, selector: string): void { @@ -44,6 +60,17 @@ function click(container: HTMLElement, selector: string): void { element.click() } +async function selectAlwaysForceDelete(container: HTMLElement): Promise { + 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 @@ -51,10 +78,11 @@ afterEach(() => { vi.clearAllMocks() }) -it('keeps Force Delete a one-time action unless the checkbox is selected', async () => { +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('[role="checkbox"]')?.getAttribute('aria-checked')).toBe('false') + 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"]')) @@ -62,6 +90,25 @@ it('keeps Force Delete a one-time action unless the checkbox is selected', async 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( @@ -71,9 +118,7 @@ it('waits for the preference to persist before force-deleting and dismissing', a }) ) const { container, onForceDelete } = renderRecovery(savePreference) - await act(async () => click(container, '[role="checkbox"]')) - expect(savePreference).not.toHaveBeenCalled() - await act(async () => click(container, 'button[data-variant="destructive"]')) + await selectAlwaysForceDelete(container) expect(savePreference).toHaveBeenCalledOnce() expect( @@ -90,8 +135,7 @@ it('keeps recovery available when saving the preference fails', async () => { const { container, onForceDelete } = renderRecovery( vi.fn().mockRejectedValue(new Error('Disk full')) ) - await act(async () => click(container, '[role="checkbox"]')) - await act(async () => click(container, 'button[data-variant="destructive"]')) + await selectAlwaysForceDelete(container) expect(onForceDelete).not.toHaveBeenCalled() expect(toast.dismiss).not.toHaveBeenCalled() @@ -105,5 +149,5 @@ it('keeps recovery available when saving the preference fails', async () => { it('does not offer the preference for a failure that cannot be force-deleted', () => { const { container } = renderRecovery(vi.fn(), false) - expect(container.querySelector('[role="checkbox"]')).toBeNull() + expect(container.querySelector('button[aria-label="More force delete options"]')).toBeNull() }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx index 7705ae60acdb..8dc934ae4261 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx @@ -1,11 +1,9 @@ import { toast } from 'sonner' -import { useId, useState } from 'react' import { Button } from '../ui/button' -import { Checkbox } from '../ui/checkbox' -import { Label } from '../ui/label' 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, @@ -62,30 +60,10 @@ function DeleteWorktreeFailureToastBody({ worktreeName: string onNestedDeleted?: () => void }): React.JSX.Element { - const preferenceId = useId() - const [alwaysForceDelete, setAlwaysForceDelete] = useState(false) - const [isSaving, setIsSaving] = useState(false) const viewChanges = (): void => { toast.dismiss(toastId) onViewChanges() } - const forceDelete = async (): Promise => { - if (alwaysForceDelete && onAlwaysForceDelete) { - 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 - } - } - toast.dismiss(toastId) - onForceDelete() - } const deleteAnyway = (): void => { toast.dismiss(toastId) onDeleteAnyway() @@ -96,28 +74,6 @@ function DeleteWorktreeFailureToastBody({ {description ? (

{description}

) : null} - {canForceDelete && onAlwaysForceDelete ? ( -
-
- setAlwaysForceDelete(checked === true)} - aria-describedby={`${preferenceId}-description`} - /> - -
-

- {translate( - 'workspaceDeletion.alwaysForceDeleteDescription', - 'Future deletions discard changes, even if terminal shutdown cannot be verified. Change this in Settings.' - )} -

-
- ) : null}
{showViewChanges ? ( + ) : null} {canWaiveArchiveHook ? (