diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 6c6e27778b1..765696b83f3 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -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 => { 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}`) } } ) diff --git a/src/main/ipc/filesystem/filesystem.test.ts b/src/main/ipc/filesystem/filesystem.test.ts index 5e685295987..02d2ef39aaf 100644 --- a/src/main/ipc/filesystem/filesystem.test.ts +++ b/src/main/ipc/filesystem/filesystem.test.ts @@ -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. diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index a5c73d093ef..ef0c01b985a 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -159,6 +159,7 @@ export type FilesystemApi = { targetPath: string connectionId?: string recursive?: boolean + permanent?: boolean } & SshMutationExpectation ) => Promise stat: (args: { diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 23d2691f771..793293a8af2 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -153,6 +153,7 @@ export const fsApi = { targetPath: string connectionId?: string recursive?: boolean + permanent?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:deletePath', args), stat: (args: { diff --git a/src/renderer/src/components/confirmation-dialog-context.ts b/src/renderer/src/components/confirmation-dialog-context.ts index 29feaca101f..9af074dbb0b 100644 --- a/src/renderer/src/components/confirmation-dialog-context.ts +++ b/src/renderer/src/components/confirmation-dialog-context.ts @@ -17,6 +17,7 @@ export type ConfirmationDialogOptions = { confirmLabel?: string cancelLabel?: string confirmVariant?: 'default' | 'destructive' + surface?: 'security' icon?: LucideIcon cancelVariant?: 'outline' | 'ghost' initialFocus?: 'confirm' diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index 5c145ad77a8..541f2bf6a7c 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -133,6 +133,7 @@ export function ConfirmationDialogProvider({ onOpenChange={(open) => !open && settleActiveRequest(false)} > { + 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' + }) +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-trash-fallback.ts b/src/renderer/src/components/right-sidebar/file-explorer-trash-fallback.ts new file mode 100644 index 00000000000..57845d0f7c7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-trash-fallback.ts @@ -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 + 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 } +} diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.trash-failure.test.tsx b/src/renderer/src/components/right-sidebar/useFileDeletion.trash-failure.test.tsx new file mode 100644 index 00000000000..11762f6d616 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.trash-failure.test.tsx @@ -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()), + 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['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() +}) diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 64f5cc78a78..66cbc93269d 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -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) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 65609bc9521..87ef2c830c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 80879ec61e9..533a16cbf25 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 52a68fae0e2..031bd8a90e6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index da27e03c441..9c2c4637a1b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -20555,6 +20555,12 @@ "findInFolder": "{{path}} 内を検索", "folderLoadError": "このフォルダーを読み込めませんでした:", "emptyFolder": "このフォルダーにはファイルがありません" + }, + "trashFailed": { + "titleRecycleBin": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?", + "titleTrash": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?", + "description": "この操作は取り消せません。", + "confirm": "完全に削除" } }, "sparsePreset": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dfbaa075405..75e3c573beb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -20554,6 +20554,12 @@ "findInFolder": "{{path}}에서 찾기", "folderLoadError": "이 폴더를 불러오지 못했습니다:", "emptyFolder": "이 폴더에 파일이 없습니다" + }, + "trashFailed": { + "titleRecycleBin": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?", + "titleTrash": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?", + "description": "이 작업은 되돌릴 수 없습니다.", + "confirm": "영구 삭제" } }, "sparsePreset": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index fa4ffc8426b..b1e62f753ff 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6692a85cdb9..3973e5d3bde 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20555,6 +20555,12 @@ "findInFolder": "在 {{path}} 中查找", "folderLoadError": "无法加载此文件夹:", "emptyFolder": "此文件夹中没有文件" + }, + "trashFailed": { + "titleRecycleBin": "无法将“{{name}}”移到回收站。是否永久删除?", + "titleTrash": "无法将“{{name}}”移到废纸篓。是否永久删除?", + "description": "此操作无法撤销。", + "confirm": "永久删除" } }, "sparsePreset": { diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index ad5d6f562eb..4b34a19d4e3 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -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) diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index 1bbdf854878..9b327ee3bf6 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -208,7 +208,8 @@ export async function copyRuntimePath( export async function deleteRuntimePath( context: RuntimeFileOperationArgs, targetPath: string, - recursive?: boolean + recursive?: boolean, + options?: { permanent?: boolean } ): Promise { 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 diff --git a/src/shared/trash-delete-failure.ts b/src/shared/trash-delete-failure.ts new file mode 100644 index 00000000000..09c8c3b34a6 --- /dev/null +++ b/src/shared/trash-delete-failure.ts @@ -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) +}