mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(file-manager): allow disabling move-to-trash confirmation (#1511)
This commit is contained in:
1 parent
fef33142b3
commit
e69029fbf7
5 files changed
+147
-102
No files matched your search
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user