mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -159,6 +159,7 @@ export type FilesystemApi = {
|
||||
targetPath: string
|
||||
connectionId?: string
|
||||
recursive?: boolean
|
||||
permanent?: boolean
|
||||
} & SshMutationExpectation
|
||||
) => Promise<void>
|
||||
stat: (args: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -20555,6 +20555,12 @@
|
||||
"findInFolder": "{{path}} 内を検索",
|
||||
"folderLoadError": "このフォルダーを読み込めませんでした:",
|
||||
"emptyFolder": "このフォルダーにはファイルがありません"
|
||||
},
|
||||
"trashFailed": {
|
||||
"titleRecycleBin": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?",
|
||||
"titleTrash": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?",
|
||||
"description": "この操作は取り消せません。",
|
||||
"confirm": "完全に削除"
|
||||
}
|
||||
},
|
||||
"sparsePreset": {
|
||||
|
||||
@@ -20554,6 +20554,12 @@
|
||||
"findInFolder": "{{path}}에서 찾기",
|
||||
"folderLoadError": "이 폴더를 불러오지 못했습니다:",
|
||||
"emptyFolder": "이 폴더에 파일이 없습니다"
|
||||
},
|
||||
"trashFailed": {
|
||||
"titleRecycleBin": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?",
|
||||
"titleTrash": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?",
|
||||
"description": "이 작업은 되돌릴 수 없습니다.",
|
||||
"confirm": "영구 삭제"
|
||||
}
|
||||
},
|
||||
"sparsePreset": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user