diff --git a/plugins/file-manager/locales/en.json b/plugins/file-manager/locales/en.json index 5840acb52..b1971d5a6 100644 --- a/plugins/file-manager/locales/en.json +++ b/plugins/file-manager/locales/en.json @@ -27,6 +27,10 @@ "transferConcurrency": { "label": "Simultaneous File Transfers", "description": "How many files the side by side local/remote manager uploads or downloads at the same time. Lower this if your server limits SFTP channels." + }, + "confirmBeforeTrash": { + "label": "Confirm before moving files to trash", + "description": "Ask before moving remote files to trash. Permanent deletion still requires confirmation." } } }, diff --git a/plugins/file-manager/manifest.json b/plugins/file-manager/manifest.json index da8640807..ea2b837e0 100644 --- a/plugins/file-manager/manifest.json +++ b/plugins/file-manager/manifest.json @@ -80,6 +80,13 @@ "min": 1, "max": 8, "default": 4 + }, + { + "key": "confirmBeforeTrash", + "type": "boolean", + "labelKey": "settings.user.confirmBeforeTrash.label", + "descriptionKey": "settings.user.confirmBeforeTrash.description", + "default": true } ], "host": { diff --git a/plugins/file-manager/src/frontend/FileManager.tsx b/plugins/file-manager/src/frontend/FileManager.tsx index 2fae8ab23..620d7dfa4 100644 --- a/plugins/file-manager/src/frontend/FileManager.tsx +++ b/plugins/file-manager/src/frontend/FileManager.tsx @@ -48,6 +48,7 @@ import { ConnectionLogPanel } from "@termix/plugin-sdk/ui"; import { usePluginUiPreferences, invokeAction, + useSettings, useConnectionRetry, logActivity, } from "@termix/plugin-sdk/frontend"; @@ -146,6 +147,8 @@ function FileManagerContent({ [t], ); const { confirmWithToast } = useConfirmation(); + const confirmBeforeTrash = + useSettings("user").values.confirmBeforeTrash !== false; const { addLog, clearLogs } = useConnectionLog(); const [currentHost] = useState(initialHost || null); @@ -1484,120 +1487,121 @@ function FileManagerContent({ const fullMessage = `${confirmMessage}\n\n${t("fileManager.moveToTrashWarning")}`; - confirmWithToast( - fullMessage, - async () => { - const operationPath = currentPath; - const operationSession = sshSessionId; - const deletedPaths = new Set(files.map((file) => file.path)); + const moveToTrash = async () => { + const operationPath = currentPath; + const operationSession = sshSessionId; + const deletedPaths = new Set(files.map((file) => file.path)); + commitFilesForPath( + operationSession, + operationPath, + removePaths( + filesForPath(operationSession, operationPath), + deletedPaths, + ), + ); + clearSelection(); + let completed = 0; + try { + await ensureSSHConnection(); + + for (const file of files) { + await deleteSSHItem( + sshSessionId, + file.path, + file.type === "directory", + currentHost?.id, + currentHost?.userId?.toString(), + ); + completed += 1; + } + + toast.success( + t("fileManager.itemsMovedToTrash", { count: files.length }), + ); + if ( + sshSessionIdRef.current === operationSession && + currentPathRef.current === operationPath + ) { + handleRefreshDirectory(); + } else { + invalidateCachedFileList(operationSession, operationPath); + } + } catch (error: unknown) { commitFilesForPath( operationSession, operationPath, - removePaths( + restoreItems( filesForPath(operationSession, operationPath), - deletedPaths, + files.slice(completed), ), ); - clearSelection(); - let completed = 0; - try { - await ensureSSHConnection(); - - for (const file of files) { + const axiosError = error as { + response?: { + data?: { needsSudo?: boolean; error?: string }; + status?: number; + }; + message?: string; + }; + if (axiosError.response?.data?.needsSudo) { + setPendingSudoOperation({ + type: "delete", + files: files.slice(completed), + }); + setSudoDialogOpen(true); + return; + } + if ( + (axiosError.response?.data as { trashUnavailable?: boolean }) + ?.trashUnavailable && + window.confirm(t("fileManager.trashUnavailableConfirm")) + ) { + for (const file of files.slice(completed)) { await deleteSSHItem( sshSessionId, file.path, file.type === "directory", currentHost?.id, currentHost?.userId?.toString(), + true, ); - completed += 1; } - toast.success( - t("fileManager.itemsMovedToTrash", { count: files.length }), + t("fileManager.itemsDeletedSuccessfully", { + count: files.length - completed, + }), ); - if ( - sshSessionIdRef.current === operationSession && - currentPathRef.current === operationPath - ) { - handleRefreshDirectory(); - } else { - invalidateCachedFileList(operationSession, operationPath); - } - } catch (error: unknown) { - commitFilesForPath( - operationSession, - operationPath, - restoreItems( - filesForPath(operationSession, operationPath), - files.slice(completed), - ), - ); - const axiosError = error as { - response?: { - data?: { needsSudo?: boolean; error?: string }; - status?: number; - }; - message?: string; - }; - if (axiosError.response?.data?.needsSudo) { - setPendingSudoOperation({ - type: "delete", - files: files.slice(completed), - }); - setSudoDialogOpen(true); - return; - } - if ( - (axiosError.response?.data as { trashUnavailable?: boolean }) - ?.trashUnavailable && - window.confirm(t("fileManager.trashUnavailableConfirm")) - ) { - for (const file of files.slice(completed)) { - await deleteSSHItem( - sshSessionId, - file.path, - file.type === "directory", - currentHost?.id, - currentHost?.userId?.toString(), - true, - ); - } - toast.success( - t("fileManager.itemsDeletedSuccessfully", { - count: files.length - completed, - }), - ); - handleRefreshDirectory(); - return; - } - if ( - axiosError.response?.status === 403 || - axiosError.response?.data?.error - ?.toLowerCase() - .includes("permission denied") - ) { - toast.error(t("fileManager.permissionDenied")); - } else if ( - axiosError.message?.includes("connection") || - axiosError.message?.includes("established") - ) { - toast.error( - t("fileManager.sshConnectionFailed", { - name: currentHost?.name, - ip: currentHost?.ip, - port: currentHost?.port, - }), - ); - } else { - toast.error(t("fileManager.failedToDeleteItems")); - } - console.error("Delete failed:", error); + handleRefreshDirectory(); + return; } - }, - "destructive", - ); + if ( + axiosError.response?.status === 403 || + axiosError.response?.data?.error + ?.toLowerCase() + .includes("permission denied") + ) { + toast.error(t("fileManager.permissionDenied")); + } else if ( + axiosError.message?.includes("connection") || + axiosError.message?.includes("established") + ) { + toast.error( + t("fileManager.sshConnectionFailed", { + name: currentHost?.name, + ip: currentHost?.ip, + port: currentHost?.port, + }), + ); + } else { + toast.error(t("fileManager.failedToDeleteItems")); + } + console.error("Delete failed:", error); + } + }; + if (confirmBeforeTrash) { + confirmWithToast(fullMessage, moveToTrash, "destructive"); + } else { + await moveToTrash(); + } } async function handleSudoPasswordSubmit(password: string) { diff --git a/plugins/file-manager/src/frontend/SftpTransferTab.tsx b/plugins/file-manager/src/frontend/SftpTransferTab.tsx index 2b7c4b9a5..2a973be4a 100644 --- a/plugins/file-manager/src/frontend/SftpTransferTab.tsx +++ b/plugins/file-manager/src/frontend/SftpTransferTab.tsx @@ -1,3 +1,4 @@ +import { useSettings } from "@termix/plugin-sdk/frontend"; import { fileManagerHostSetting } from "./host-settings"; import { useCallback, @@ -548,6 +549,8 @@ function RemotePane({ } export function SftpTransferTab() { + const confirmBeforeTrash = + useSettings("user").values.confirmBeforeTrash !== false; const { t } = useTranslation(); const formatTransferMetrics = useMemo( () => createFormatTransferMetrics(t), @@ -894,10 +897,10 @@ export function SftpTransferTab() { } }; - const handleDeleteConfirmed = async () => { - if (!deleteTarget) return; - const entries = getContextEntries(deleteTarget); - const paneId = deleteTarget.paneId; + const handleDeleteConfirmed = async (target = deleteTarget) => { + if (!target) return; + const entries = getContextEntries(target); + const paneId = target.paneId; setDeleteTarget(null); if (entries.length === 0) return; @@ -1071,7 +1074,8 @@ export function SftpTransferTab() { { - setDeleteTarget(contextMenu); + if (confirmBeforeTrash) setDeleteTarget(contextMenu); + else void handleDeleteConfirmed(contextMenu); setContextMenu(null); }} > diff --git a/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx b/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx index 53ca7522c..13bcf54f4 100644 --- a/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx +++ b/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SftpTransferTab } from "../../src/frontend/SftpTransferTab"; const api = vi.hoisted(() => ({ + confirmBeforeTrash: undefined as boolean | undefined, addTransferRecent: vi.fn(), browseSSHDirectory: vi.fn(), changeSSHPermissions: vi.fn(), @@ -51,12 +52,20 @@ vi.mock("react-i18next", () => ({ initReactI18next: { type: "3rdParty", init: () => {} }, })); +vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({ + ...(await importOriginal()), + useSettings: () => ({ + values: { confirmBeforeTrash: api.confirmBeforeTrash }, + }), +})); + vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); beforeEach(() => { vi.clearAllMocks(); + api.confirmBeforeTrash = undefined; api.getSSHHosts.mockResolvedValue([ { id: 1, @@ -206,12 +215,29 @@ describe("SftpTransferTab", () => { }); }); + it("moves files to trash directly when confirmation is disabled", async () => { + api.confirmBeforeTrash = false; + render(); + await selectHosts(); + fireEvent.contextMenu(screen.getByText("remote-1.txt")); + await userEvent.click(screen.getByText("sftpTransfer.delete")); + await waitFor(() => + expect(api.deleteSSHItem).toHaveBeenCalledWith( + "1", + "/srv/remote-1.txt", + false, + ), + ); + expect(screen.queryByText("sftpTransfer.deleteSelectedItems")).toBeNull(); + }); + it("deletes a remote file after confirming", async () => { render(); await selectHosts(); fireEvent.contextMenu(screen.getByText("remote-1.txt")); await userEvent.click(screen.getByText("sftpTransfer.delete")); + expect(api.deleteSSHItem).not.toHaveBeenCalled(); const confirmButtons = await screen.findAllByText("sftpTransfer.delete"); await userEvent.click(confirmButtons[confirmButtons.length - 1]);