feat(file-manager): allow disabling move-to-trash confirmation (#1511)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:46:56 -05:00
1 parent fef33142b3
commit e69029fbf7
5 files changed
+147 -102

No files matched your search

+4
View File
@@ -27,6 +27,10 @@
"transferConcurrency": { "transferConcurrency": {
"label": "Simultaneous File Transfers", "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." "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."
} }
} }
}, },
+7
View File
@@ -80,6 +80,13 @@
"min": 1, "min": 1,
"max": 8, "max": 8,
"default": 4 "default": 4
},
{
"key": "confirmBeforeTrash",
"type": "boolean",
"labelKey": "settings.user.confirmBeforeTrash.label",
"descriptionKey": "settings.user.confirmBeforeTrash.description",
"default": true
} }
], ],
"host": { "host": {
+101 -97
View File
@@ -48,6 +48,7 @@ import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import { import {
usePluginUiPreferences, usePluginUiPreferences,
invokeAction, invokeAction,
useSettings,
useConnectionRetry, useConnectionRetry,
logActivity, logActivity,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
@@ -146,6 +147,8 @@ function FileManagerContent({
[t], [t],
); );
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const confirmBeforeTrash =
useSettings("user").values.confirmBeforeTrash !== false;
const { addLog, clearLogs } = useConnectionLog(); const { addLog, clearLogs } = useConnectionLog();
const [currentHost] = useState<SSHHost | null>(initialHost || null); const [currentHost] = useState<SSHHost | null>(initialHost || null);
@@ -1484,120 +1487,121 @@ function FileManagerContent({
const fullMessage = `${confirmMessage}\n\n${t("fileManager.moveToTrashWarning")}`; const fullMessage = `${confirmMessage}\n\n${t("fileManager.moveToTrashWarning")}`;
confirmWithToast( const moveToTrash = async () => {
fullMessage, const operationPath = currentPath;
async () => { const operationSession = sshSessionId;
const operationPath = currentPath; const deletedPaths = new Set(files.map((file) => file.path));
const operationSession = sshSessionId; commitFilesForPath(
const deletedPaths = new Set(files.map((file) => file.path)); 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( commitFilesForPath(
operationSession, operationSession,
operationPath, operationPath,
removePaths( restoreItems(
filesForPath(operationSession, operationPath), filesForPath(operationSession, operationPath),
deletedPaths, files.slice(completed),
), ),
); );
clearSelection(); const axiosError = error as {
let completed = 0; response?: {
try { data?: { needsSudo?: boolean; error?: string };
await ensureSSHConnection(); status?: number;
};
for (const file of files) { 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( await deleteSSHItem(
sshSessionId, sshSessionId,
file.path, file.path,
file.type === "directory", file.type === "directory",
currentHost?.id, currentHost?.id,
currentHost?.userId?.toString(), currentHost?.userId?.toString(),
true,
); );
completed += 1;
} }
toast.success( toast.success(
t("fileManager.itemsMovedToTrash", { count: files.length }), t("fileManager.itemsDeletedSuccessfully", {
count: files.length - completed,
}),
); );
if ( handleRefreshDirectory();
sshSessionIdRef.current === operationSession && return;
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);
} }
}, if (
"destructive", 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) { async function handleSudoPasswordSubmit(password: string) {
@@ -1,3 +1,4 @@
import { useSettings } from "@termix/plugin-sdk/frontend";
import { fileManagerHostSetting } from "./host-settings"; import { fileManagerHostSetting } from "./host-settings";
import { import {
useCallback, useCallback,
@@ -548,6 +549,8 @@ function RemotePane({
} }
export function SftpTransferTab() { export function SftpTransferTab() {
const confirmBeforeTrash =
useSettings("user").values.confirmBeforeTrash !== false;
const { t } = useTranslation(); const { t } = useTranslation();
const formatTransferMetrics = useMemo( const formatTransferMetrics = useMemo(
() => createFormatTransferMetrics(t), () => createFormatTransferMetrics(t),
@@ -894,10 +897,10 @@ export function SftpTransferTab() {
} }
}; };
const handleDeleteConfirmed = async () => { const handleDeleteConfirmed = async (target = deleteTarget) => {
if (!deleteTarget) return; if (!target) return;
const entries = getContextEntries(deleteTarget); const entries = getContextEntries(target);
const paneId = deleteTarget.paneId; const paneId = target.paneId;
setDeleteTarget(null); setDeleteTarget(null);
if (entries.length === 0) return; if (entries.length === 0) return;
@@ -1071,7 +1074,8 @@ export function SftpTransferTab() {
<ContextMenuButton <ContextMenuButton
disabled={!canMutateContextPane || contextEntries.length === 0} disabled={!canMutateContextPane || contextEntries.length === 0}
onClick={() => { onClick={() => {
setDeleteTarget(contextMenu); if (confirmBeforeTrash) setDeleteTarget(contextMenu);
else void handleDeleteConfirmed(contextMenu);
setContextMenu(null); setContextMenu(null);
}} }}
> >
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { SftpTransferTab } from "../../src/frontend/SftpTransferTab"; import { SftpTransferTab } from "../../src/frontend/SftpTransferTab";
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
confirmBeforeTrash: undefined as boolean | undefined,
addTransferRecent: vi.fn(), addTransferRecent: vi.fn(),
browseSSHDirectory: vi.fn(), browseSSHDirectory: vi.fn(),
changeSSHPermissions: vi.fn(), changeSSHPermissions: vi.fn(),
@@ -51,12 +52,20 @@ vi.mock("react-i18next", () => ({
initReactI18next: { type: "3rdParty", init: () => {} }, initReactI18next: { type: "3rdParty", init: () => {} },
})); }));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useSettings: () => ({
values: { confirmBeforeTrash: api.confirmBeforeTrash },
}),
}));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() }, toast: { success: vi.fn(), error: vi.fn() },
})); }));
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
api.confirmBeforeTrash = undefined;
api.getSSHHosts.mockResolvedValue([ api.getSSHHosts.mockResolvedValue([
{ {
id: 1, id: 1,
@@ -206,12 +215,29 @@ describe("SftpTransferTab", () => {
}); });
}); });
it("moves files to trash directly when confirmation is disabled", async () => {
api.confirmBeforeTrash = false;
render(<SftpTransferTab />);
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 () => { it("deletes a remote file after confirming", async () => {
render(<SftpTransferTab />); render(<SftpTransferTab />);
await selectHosts(); await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt")); fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.delete")); await userEvent.click(screen.getByText("sftpTransfer.delete"));
expect(api.deleteSSHItem).not.toHaveBeenCalled();
const confirmButtons = await screen.findAllByText("sftpTransfer.delete"); const confirmButtons = await screen.findAllByText("sftpTransfer.delete");
await userEvent.click(confirmButtons[confirmButtons.length - 1]); await userEvent.click(confirmButtons[confirmButtons.length - 1]);