fix(file-explorer): offer permanent delete when moving to Trash fails (#33956)

Offer confirmed permanent deletion when moving an item to Trash fails.
Preserve authorization and workspace ownership checks, cancellation behavior,
text-snapshot undo, remote compatibility, and all supported translations.

Fixes #33944

Co-authored-by: nori <dosukoicrypto@gmail.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
nori
2026-10-26 22:25:01 -07:00
committed by GitHub
co-authored by nori OrcaWin
parent 362104ca18
commit a7c9d19048
20 changed files with 482 additions and 27 deletions
@@ -1,5 +1,5 @@
import { ipcMain, shell } from 'electron'
import { lstat } from 'node:fs/promises'
import { lstat, rm } from 'node:fs/promises'
import type { SshMutationExpectation } from '../../../shared/ssh-types'
import { assertSshMutationExpectation } from '../../ssh/connection/ssh-connection-generation'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
@@ -19,6 +19,7 @@ import type {
} from '../../../shared/file-write-disk-precondition'
import { writeLocalTextFileWithDiskPrecondition } from './local-disk-write-precondition'
import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path'
import { TRASH_DELETE_FAILED_MESSAGE } from '../../../shared/trash-delete-failure'
export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void {
const { store } = context
@@ -67,6 +68,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
targetPath: string
connectionId?: string
recursive?: boolean
permanent?: boolean
} & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
@@ -86,6 +88,10 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) {
return
}
if (args.permanent === true) {
await rm(windowsFsPath(targetPath), { recursive: args.recursive === true, force: true })
return
}
// Concurrent external deletion must leave UI deletion idempotent.
try {
await shell.trashItem(targetPath)
@@ -93,7 +99,9 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
if (isENOENT(error)) {
return
}
throw error
// Tagged so the renderer can offer permanent deletion (e.g. drive with Recycle Bin disabled).
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`${TRASH_DELETE_FAILED_MESSAGE}: ${reason}`)
}
}
)
@@ -1,11 +1,13 @@
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TRASH_DELETE_FAILED_MESSAGE } from '../../../shared/trash-delete-failure'
import {
handlers,
store,
dirEntry,
REPO_PATH,
trashItemMock,
rmMock,
readdirMock,
readFileMock,
writeFileMock,
@@ -456,6 +458,65 @@ describe('registerFilesystemHandlers', () => {
expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(targetPath, { recursive: undefined })
})
it('tags a Trash failure so the renderer can offer permanent deletion', async () => {
// SAFETY: the fixture supplies the store methods used by filesystem authorization.
registerFilesystemHandlers(store as never)
const targetPath = path.resolve('/workspace/repo/file.txt')
trashItemMock.mockRejectedValue(new Error('Failed to perform delete operation'))
await expect(handlers.get('fs:deletePath')!(null, { targetPath })).rejects.toThrow(
`${TRASH_DELETE_FAILED_MESSAGE}: Failed to perform delete operation`
)
expect(rmMock).not.toHaveBeenCalled()
})
it('hard-deletes without Trash only when permanent is exactly true', async () => {
// SAFETY: the fixture supplies the store methods used by filesystem authorization.
registerFilesystemHandlers(store as never)
const targetPath = path.resolve('/workspace/repo/dir')
await handlers.get('fs:deletePath')!(null, { targetPath, recursive: true, permanent: 'yes' })
expect(rmMock).not.toHaveBeenCalled()
expect(trashItemMock).toHaveBeenCalledWith(targetPath)
trashItemMock.mockClear()
await handlers.get('fs:deletePath')!(null, { targetPath, recursive: true, permanent: true })
expect(rmMock).toHaveBeenCalledWith(targetPath, { recursive: true, force: true })
expect(trashItemMock).not.toHaveBeenCalled()
})
it('authorizes a permanent retry and propagates filesystem failures without a Trash tag', async () => {
// SAFETY: the fixture supplies the store methods used by filesystem authorization.
registerFilesystemHandlers(store as never)
const targetPath = path.resolve('/workspace/repo/file.txt')
await expect(
handlers.get('fs:deletePath')!(null, {
targetPath: path.resolve('/outside-workspace/file.txt'),
permanent: true
})
).rejects.toThrow('Access denied')
expect(rmMock).not.toHaveBeenCalled()
expect(trashItemMock).not.toHaveBeenCalled()
rmMock.mockRejectedValueOnce(new Error('EACCES: permission denied'))
await expect(
handlers.get('fs:deletePath')!(null, { targetPath, permanent: true })
).rejects.toThrow('EACCES: permission denied')
expect(trashItemMock).not.toHaveBeenCalled()
})
it('does not offer a permanent retry when the trashed item is already missing', async () => {
// SAFETY: the fixture supplies the store methods used by filesystem authorization.
registerFilesystemHandlers(store as never)
trashItemMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
await expect(
handlers.get('fs:deletePath')!(null, { targetPath: path.resolve('/workspace/repo/file.txt') })
).resolves.toBeUndefined()
expect(rmMock).not.toHaveBeenCalled()
})
// WSL UNC has no Recycle Bin; delete inside the distro instead (#6415).
it('hard-deletes a WSL UNC path instead of trashing it', async () => {
// Build with host path semantics so root/child containment also holds on POSIX CI.
+1
View File
@@ -159,6 +159,7 @@ export type FilesystemApi = {
targetPath: string
connectionId?: string
recursive?: boolean
permanent?: boolean
} & SshMutationExpectation
) => Promise<void>
stat: (args: {
+1
View File
@@ -153,6 +153,7 @@ export const fsApi = {
targetPath: string
connectionId?: string
recursive?: boolean
permanent?: boolean
} & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
stat: (args: {
@@ -17,6 +17,7 @@ export type ConfirmationDialogOptions = {
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'default' | 'destructive'
surface?: 'security'
icon?: LucideIcon
cancelVariant?: 'outline' | 'ghost'
initialFocus?: 'confirm'
@@ -133,6 +133,7 @@ export function ConfirmationDialogProvider({
onOpenChange={(open) => !open && settleActiveRequest(false)}
>
<DialogContent
surface={displayedRequest?.options.surface}
showCloseButton={false}
size={compact ? 'compact' : 'default'}
className={cn('sm:max-w-md', Icon && 'gap-6')}
@@ -1,3 +1,4 @@
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
import { translate } from '@/i18n/i18n'
import {
getFileExplorerOperationRoute,
@@ -25,3 +26,28 @@ export function getFileDeleteErrorMessage(error: unknown): string | null {
)
: error.message
}
// Remote deletes bypass Trash and may be unrecoverable.
export function confirmRemoteDelete(
confirm: ConfirmationDialogContextValue,
node: TreeNode
): Promise<boolean> {
return confirm({
title: translate(
'auto.components.right.sidebar.useFileDeletion.d979a4fbb5',
"Permanently delete '{{value0}}'?",
{ value0: node.name }
),
description: node.isDirectory
? translate(
'auto.components.right.sidebar.useFileDeletion.7fb9435c86',
'This permanently deletes the directory and its contents on the remote host. This cannot be undone.'
)
: translate(
'auto.components.right.sidebar.useFileDeletion.23e98f192f',
'This permanently deletes the file on the remote host. This cannot be undone.'
),
confirmLabel: translate('auto.components.right.sidebar.useFileDeletion.92276aceb7', 'Delete'),
confirmVariant: 'destructive'
})
}
@@ -0,0 +1,55 @@
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
import { translate } from '@/i18n/i18n'
import { isTrashDeleteFailure } from '../../../../shared/trash-delete-failure'
type DeleteWithPermanentFallbackParams = {
name: string
isRemote: boolean
isWindows: boolean
confirm: ConfirmationDialogContextValue
deletePath: (options?: { permanent?: boolean }) => Promise<void>
beforePermanentDelete: () => void
}
// Null means cancellation; only a failed local Trash operation permits the permanent retry.
export async function deleteWithPermanentFallback({
name,
isRemote,
isWindows,
confirm,
deletePath,
beforePermanentDelete
}: DeleteWithPermanentFallbackParams): Promise<{ permanent: boolean } | null> {
try {
await deletePath()
return { permanent: false }
} catch (error) {
if (isRemote || !isTrashDeleteFailure(error)) {
throw error
}
}
const confirmed = await confirm({
title: isWindows
? translate(
'fileExplorer.trashFailed.titleRecycleBin',
"Couldn't move '{{name}}' to the Recycle Bin. Delete it permanently?",
{ name }
)
: translate(
'fileExplorer.trashFailed.titleTrash',
"Couldn't move '{{name}}' to the Trash. Delete it permanently?",
{ name }
),
description: translate('fileExplorer.trashFailed.description', 'This cannot be undone.'),
confirmLabel: translate('fileExplorer.trashFailed.confirm', 'Delete Permanently'),
confirmVariant: 'destructive',
cancelVariant: 'ghost',
surface: 'security'
})
if (!confirmed) {
return null
}
beforePermanentDelete()
await deletePath({ permanent: true })
return { permanent: true }
}
@@ -0,0 +1,211 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TRASH_DELETE_FAILED_MESSAGE } from '../../../../shared/trash-delete-failure'
import { useFileDeletion } from './useFileDeletion'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
deleteRuntimePath: vi.fn(),
assertCurrent: vi.fn(),
commitFileExplorerOp: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/components/confirmation-dialog-context', () => ({
useConfirmationDialog: () => mocks.confirm
}))
vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Del' }))
vi.mock('./file-explorer-operation-owner', async (importOriginal) => ({
...(await importOriginal<object>()),
captureFileExplorerOperationGuard: () => ({
assertCurrent: mocks.assertCurrent
})
}))
vi.mock('./file-explorer-deletion-editors', () => ({
getFileExplorerDeletionEditors: () => [{ id: 'tab-1', isDirty: false }]
}))
vi.mock('@/components/editor/editor-autosave', () => ({
requestEditorFileSave: vi.fn(async () => undefined),
requestEditorSaveQuiesce: vi.fn(async () => undefined)
}))
vi.mock('@/runtime/runtime-file-mutation-client', () => ({
deleteRuntimePath: mocks.deleteRuntimePath,
writeRuntimeFile: vi.fn()
}))
vi.mock('@/runtime/runtime-file-read-client', () => ({
readRuntimeFileContent: vi.fn(async () => ({ content: 'hello', isBinary: false }))
}))
vi.mock('./fileExplorerUndoRedo', () => ({ commitFileExplorerOp: mocks.commitFileExplorerOp }))
vi.mock('@/store', () => ({
useAppStore: { getState: () => ({ getKnownWorktreeById: () => null }), setState: vi.fn() }
}))
const trashFailure = new Error(
`Error invoking remote method 'fs:deletePath': Error: ${TRASH_DELETE_FAILED_MESSAGE}: Failed to perform delete operation`
)
function renderDeletion(isWindows = true) {
const closeFile = vi.fn()
const refreshDir = vi.fn(async () => undefined)
const { result } = renderHook(() =>
useFileDeletion({
activeWorktreeId: 'wt-1',
openFiles: [],
closeFile,
refreshDir,
setSelectedPaths: vi.fn(),
isWindows
})
)
return { result, closeFile, refreshDir }
}
async function requestDelete(result: ReturnType<typeof renderDeletion>['result']) {
await act(async () => {
result.current.requestDelete({
name: 'file.ts',
path: 'C:\\repo\\file.ts',
relativePath: 'file.ts',
isDirectory: false,
depth: 0,
operationOwner: { kind: 'local' }
})
})
}
beforeEach(() => {
vi.clearAllMocks()
mocks.deleteRuntimePath.mockReset()
mocks.confirm.mockReset()
mocks.assertCurrent.mockReturnValue({
target: { kind: 'local' },
expectedExecutionHostId: 'local'
})
})
afterEach(() => {
cleanup()
})
it('permanently deletes after the user confirms a Trash failure', async () => {
mocks.deleteRuntimePath.mockRejectedValueOnce(trashFailure).mockResolvedValueOnce(undefined)
mocks.confirm.mockResolvedValue(true)
const { result, closeFile } = renderDeletion()
await requestDelete(result)
await vi.waitFor(() => expect(closeFile).toHaveBeenCalledWith('tab-1'))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: "Couldn't move 'file.ts' to the Recycle Bin. Delete it permanently?",
description: 'This cannot be undone.',
confirmLabel: 'Delete Permanently',
confirmVariant: 'destructive',
cancelVariant: 'ghost',
surface: 'security'
})
)
expect(mocks.deleteRuntimePath).toHaveBeenLastCalledWith(
expect.anything(),
'C:\\repo\\file.ts',
false,
{ permanent: true }
)
expect(mocks.toastError).not.toHaveBeenCalled()
// Undo restores from the pre-delete snapshot; redo must not retry the failing Trash path.
const op = mocks.commitFileExplorerOp.mock.calls[0][0]
await op.redo()
expect(mocks.deleteRuntimePath).toHaveBeenLastCalledWith(
expect.anything(),
'C:\\repo\\file.ts',
false,
{ permanent: true }
)
})
it('keeps the file and stays quiet when the user cancels', async () => {
mocks.deleteRuntimePath.mockRejectedValueOnce(trashFailure)
mocks.confirm.mockResolvedValue(false)
const { result, closeFile, refreshDir } = renderDeletion(false)
await requestDelete(result)
await vi.waitFor(() => expect(mocks.confirm).toHaveBeenCalled())
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: "Couldn't move 'file.ts' to the Trash. Delete it permanently?"
})
)
expect(mocks.deleteRuntimePath).toHaveBeenCalledTimes(1)
expect(closeFile).not.toHaveBeenCalled()
expect(refreshDir).not.toHaveBeenCalled()
expect(mocks.commitFileExplorerOp).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('reports an error when the permanent delete also fails', async () => {
mocks.deleteRuntimePath
.mockRejectedValueOnce(trashFailure)
.mockRejectedValueOnce(new Error('EBUSY: resource busy or locked'))
mocks.confirm.mockResolvedValue(true)
const { result, closeFile, refreshDir } = renderDeletion()
await requestDelete(result)
await vi.waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith('EBUSY: resource busy or locked')
)
expect(closeFile).not.toHaveBeenCalled()
expect(refreshDir).not.toHaveBeenCalled()
expect(mocks.commitFileExplorerOp).not.toHaveBeenCalled()
})
it('does not offer permanent deletion for errors other than a Trash failure', async () => {
mocks.deleteRuntimePath.mockRejectedValueOnce(new Error('Access denied: path outside workspace'))
const { result, closeFile } = renderDeletion()
await requestDelete(result)
await vi.waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith('Access denied: path outside workspace')
)
expect(mocks.confirm).not.toHaveBeenCalled()
expect(mocks.deleteRuntimePath).toHaveBeenCalledTimes(1)
expect(closeFile).not.toHaveBeenCalled()
})
it('aborts a permanent retry if the workspace host changes while confirmation is open', async () => {
mocks.deleteRuntimePath.mockRejectedValueOnce(trashFailure)
mocks.confirm.mockImplementationOnce(async () => {
mocks.assertCurrent.mockImplementation(() => {
throw new Error('Workspace host changed; refresh and try again')
})
return true
})
const { result, closeFile, refreshDir } = renderDeletion()
await requestDelete(result)
await vi.waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith('Workspace host changed; refresh and try again')
)
expect(mocks.deleteRuntimePath).toHaveBeenCalledTimes(1)
expect(closeFile).not.toHaveBeenCalled()
expect(refreshDir).not.toHaveBeenCalled()
expect(mocks.commitFileExplorerOp).not.toHaveBeenCalled()
})
it('keeps successful Trash deletion prompt-free', async () => {
mocks.deleteRuntimePath.mockResolvedValueOnce(undefined)
const { result, closeFile } = renderDeletion()
await requestDelete(result)
await vi.waitFor(() => expect(closeFile).toHaveBeenCalledWith('tab-1'))
expect(mocks.confirm).not.toHaveBeenCalled()
expect(mocks.deleteRuntimePath).toHaveBeenCalledTimes(1)
expect(mocks.toastError).not.toHaveBeenCalled()
})
@@ -13,6 +13,7 @@ import {
type FileExplorerDeletionEditor
} from './file-explorer-deletion-editors'
import {
confirmRemoteDelete,
getFileDeleteErrorMessage,
isLocalDeleteNode,
needsRemoteDeleteConfirmation
@@ -27,6 +28,7 @@ import {
assertFileWriteApplied,
missingOrIdenticalPrecondition
} from '../../../../shared/file-write-disk-precondition'
import { deleteWithPermanentFallback } from './file-explorer-trash-fallback'
import { readRuntimeFileContent } from '@/runtime/runtime-file-read-client'
import { translate } from '@/i18n/i18n'
@@ -76,27 +78,7 @@ export function useFileDeletion({
)
// Remote deletes bypass Trash and may be unrecoverable; batches confirm once up front.
if (isRemote && !options?.skipConfirmation) {
const confirmed = await confirm({
title: translate(
'auto.components.right.sidebar.useFileDeletion.d979a4fbb5',
"Permanently delete '{{value0}}'?",
{ value0: node.name }
),
description: node.isDirectory
? translate(
'auto.components.right.sidebar.useFileDeletion.7fb9435c86',
'This permanently deletes the directory and its contents on the remote host. This cannot be undone.'
)
: translate(
'auto.components.right.sidebar.useFileDeletion.23e98f192f',
'This permanently deletes the file on the remote host. This cannot be undone.'
),
confirmLabel: translate(
'auto.components.right.sidebar.useFileDeletion.92276aceb7',
'Delete'
),
confirmVariant: 'destructive'
})
const confirmed = await confirmRemoteDelete(confirm, node)
if (!confirmed) {
return false
}
@@ -150,7 +132,19 @@ export function useFileDeletion({
}
operationGuard.assertCurrent()
await deleteRuntimePath(fileContext, node.path, node.isDirectory)
const deletion = await deleteWithPermanentFallback({
name: node.name,
isRemote,
isWindows,
confirm,
deletePath: (deleteOptions) =>
deleteRuntimePath(fileContext, node.path, node.isDirectory, deleteOptions),
beforePermanentDelete: () => operationGuard.assertCurrent()
})
if (!deletion) {
return false
}
const { permanent } = deletion
if (undoContent !== undefined) {
commitFileExplorerOp({
@@ -181,7 +175,8 @@ export function useFileDeletion({
connectionId: currentRoute.connectionId
},
node.path,
node.isDirectory
node.isDirectory,
{ permanent }
)
await refreshDir(parentDir)
}
+6
View File
@@ -20893,6 +20893,12 @@
"findInFolder": "Find in {{path}}",
"folderLoadError": "Could not load this folder:",
"emptyFolder": "No files in this folder"
},
"trashFailed": {
"titleRecycleBin": "Couldn't move '{{name}}' to the Recycle Bin. Delete it permanently?",
"titleTrash": "Couldn't move '{{name}}' to the Trash. Delete it permanently?",
"description": "This cannot be undone.",
"confirm": "Delete Permanently"
}
},
"sparsePreset": {
+6
View File
@@ -20384,6 +20384,12 @@
"findInFolder": "Buscar en {{path}}",
"folderLoadError": "No se pudo cargar esta carpeta:",
"emptyFolder": "No hay archivos en esta carpeta"
},
"trashFailed": {
"titleRecycleBin": "No se pudo mover '{{name}}' a la Papelera de reciclaje. ¿Eliminarlo permanentemente?",
"titleTrash": "No se pudo mover '{{name}}' a la Papelera. ¿Eliminarlo permanentemente?",
"description": "Esto no se puede deshacer.",
"confirm": "Eliminar permanentemente"
}
},
"sparsePreset": {
+6
View File
@@ -20555,6 +20555,12 @@
"findInFolder": "Rechercher dans {{path}}",
"folderLoadError": "Impossible de charger ce dossier :",
"emptyFolder": "Aucun fichier dans ce dossier"
},
"trashFailed": {
"titleRecycleBin": "Impossible de déplacer « {{name}} » vers la Corbeille. Le supprimer définitivement ?",
"titleTrash": "Impossible de déplacer « {{name}} » vers la Corbeille. Le supprimer définitivement ?",
"description": "Cette action est irréversible.",
"confirm": "Supprimer définitivement"
}
},
"sparsePreset": {
+6
View File
@@ -20555,6 +20555,12 @@
"findInFolder": "{{path}} 内を検索",
"folderLoadError": "このフォルダーを読み込めませんでした:",
"emptyFolder": "このフォルダーにはファイルがありません"
},
"trashFailed": {
"titleRecycleBin": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?",
"titleTrash": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?",
"description": "この操作は取り消せません。",
"confirm": "完全に削除"
}
},
"sparsePreset": {
+6
View File
@@ -20554,6 +20554,12 @@
"findInFolder": "{{path}}에서 찾기",
"folderLoadError": "이 폴더를 불러오지 못했습니다:",
"emptyFolder": "이 폴더에 파일이 없습니다"
},
"trashFailed": {
"titleRecycleBin": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?",
"titleTrash": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?",
"description": "이 작업은 되돌릴 수 없습니다.",
"confirm": "영구 삭제"
}
},
"sparsePreset": {
+6
View File
@@ -20680,6 +20680,12 @@
"findInFolder": "Buscar em {{path}}",
"folderLoadError": "Não foi possível carregar esta pasta:",
"emptyFolder": "Nenhum arquivo nesta pasta"
},
"trashFailed": {
"titleRecycleBin": "Não foi possível mover '{{name}}' para a Lixeira. Excluir permanentemente?",
"titleTrash": "Não foi possível mover '{{name}}' para a Lixeira. Excluir permanentemente?",
"description": "Isso não pode ser desfeito.",
"confirm": "Excluir permanentemente"
}
},
"sparsePreset": {
+6
View File
@@ -20555,6 +20555,12 @@
"findInFolder": "在 {{path}} 中查找",
"folderLoadError": "无法加载此文件夹:",
"emptyFolder": "此文件夹中没有文件"
},
"trashFailed": {
"titleRecycleBin": "无法将“{{name}}”移到回收站。是否永久删除?",
"titleTrash": "无法将“{{name}}”移到废纸篓。是否永久删除?",
"description": "此操作无法撤销。",
"confirm": "永久删除"
}
},
"sparsePreset": {
@@ -23,6 +23,51 @@ import {
installRuntimeFileClientEnvironment()
describe('runtime file client', () => {
it('sends the confirmed permanent-delete option only to the local filesystem', async () => {
await deleteRuntimePath(
{ target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo' },
'/repo/file.txt',
false,
{ permanent: true }
)
expect(fsDeletePath).toHaveBeenCalledWith({
targetPath: '/repo/file.txt',
connectionId: undefined,
expectedExecutionHostId: 'local',
recursive: false,
permanent: true
})
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
await deleteRuntimePath(
{
target: { kind: 'environment', environmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/remote/repo'
},
'/remote/repo/file.txt',
false,
{ permanent: true }
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'files.delete',
params: {
worktree: 'id:wt-1',
relativePath: 'file.txt',
recursive: false,
expectedExecutionHostId: 'local'
},
timeoutMs: 15_000
})
expect(fsDeletePath).toHaveBeenCalledTimes(1)
})
it('uses local filesystem reads when no remote runtime is active', async () => {
const localResult: RuntimeReadableFileContent = { content: 'hello', isBinary: false }
fsReadFile.mockResolvedValue(localResult)
@@ -208,7 +208,8 @@ export async function copyRuntimePath(
export async function deleteRuntimePath(
context: RuntimeFileOperationArgs,
targetPath: string,
recursive?: boolean
recursive?: boolean,
options?: { permanent?: boolean }
): Promise<void> {
const remoteArgs = getRemoteFileArgs(context, targetPath)
if (!remoteArgs) {
@@ -217,7 +218,8 @@ export async function deleteRuntimePath(
withSshMutationExpectation(context, {
targetPath,
connectionId: context.connectionId,
recursive
recursive,
...(options?.permanent === true ? { permanent: true } : {})
})
)
return
+6
View File
@@ -0,0 +1,6 @@
export const TRASH_DELETE_FAILED_MESSAGE = 'Failed to move item to the Trash'
// Electron IPC prefixes remote errors, so match by substring.
export function isTrashDeleteFailure(error: unknown): boolean {
return error instanceof Error && error.message.includes(TRASH_DELETE_FAILED_MESSAGE)
}