diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 6ec53e3e..f061c721 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2681,9 +2681,45 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { exitCode: code, error: errorOutput, }); - return res.status(500).json({ - error: errorOutput || "Failed to extract archive" - }); + + // Check if command not found + let friendlyError = errorOutput || "Failed to extract archive"; + if (errorOutput.includes("command not found") || errorOutput.includes("not found")) { + // Detect which command is missing based on file extension + let missingCmd = ""; + let installHint = ""; + + if (fileExt.endsWith(".zip")) { + missingCmd = "unzip"; + installHint = "apt install unzip / yum install unzip / brew install unzip"; + } else if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz") || + fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2") || + fileExt.endsWith(".tar.xz") || fileExt.endsWith(".tar")) { + missingCmd = "tar"; + installHint = "Usually pre-installed on Linux/Unix systems"; + } else if (fileExt.endsWith(".gz")) { + missingCmd = "gunzip"; + installHint = "apt install gzip / yum install gzip / Usually pre-installed"; + } else if (fileExt.endsWith(".bz2")) { + missingCmd = "bunzip2"; + installHint = "apt install bzip2 / yum install bzip2 / brew install bzip2"; + } else if (fileExt.endsWith(".xz")) { + missingCmd = "unxz"; + installHint = "apt install xz-utils / yum install xz / brew install xz"; + } else if (fileExt.endsWith(".7z")) { + missingCmd = "7z"; + installHint = "apt install p7zip-full / yum install p7zip / brew install p7zip"; + } else if (fileExt.endsWith(".rar")) { + missingCmd = "unrar"; + installHint = "apt install unrar / yum install unrar / brew install unrar"; + } + + if (missingCmd) { + friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`; + } + } + + return res.status(500).json({ error: friendlyError }); } fileLogger.success("Archive extracted successfully", { @@ -2713,6 +2749,162 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { }); }); +// Route: Compress files/folders (requires JWT) +// POST /ssh/file_manager/ssh/compressFiles +app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { + const { sessionId, paths, archiveName, format } = req.body; + + if (!sessionId || !paths || !Array.isArray(paths) || paths.length === 0 || !archiveName) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "SSH session not connected" }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + // Determine compression format + const compressionFormat = format || "zip"; // Default to zip + let compressCommand = ""; + + // Get the directory where the first file is located + const firstPath = paths[0]; + const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/"; + + // Extract just the file/folder names for the command + const fileNames = paths.map(p => { + const name = p.split("/").pop(); + return `"${name}"`; + }).join(" "); + + // Construct archive path + let archivePath = ""; + if (archiveName.includes("/")) { + archivePath = archiveName; + } else { + archivePath = workingDir.endsWith("/") + ? `${workingDir}${archiveName}` + : `${workingDir}/${archiveName}`; + } + + if (compressionFormat === "zip") { + // Use zip command - need to cd to directory first + compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") { + compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") { + compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.xz") { + compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar") { + compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "7z") { + compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`; + } else { + return res.status(400).json({ error: "Unsupported compression format" }); + } + + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + command: compressCommand, + }); + + session.client.exec(compressCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH exec error during compress:", err, { + operation: "compress_files", + sessionId, + paths, + }); + return res.status(500).json({ error: "Failed to execute compress command" }); + } + + let errorOutput = ""; + + stream.on("data", (data: Buffer) => { + fileLogger.debug("Compress stdout", { + operation: "compress_files", + sessionId, + output: data.toString(), + }); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + fileLogger.debug("Compress stderr", { + operation: "compress_files", + sessionId, + error: data.toString(), + }); + }); + + stream.on("close", (code: number) => { + if (code !== 0) { + fileLogger.error("Compress command failed", { + operation: "compress_files", + sessionId, + paths, + archivePath, + exitCode: code, + error: errorOutput, + }); + + // Check if command not found + let friendlyError = errorOutput || "Failed to compress files"; + if (errorOutput.includes("command not found") || errorOutput.includes("not found")) { + const commandMap: Record = { + "zip": { cmd: "zip", install: "apt install zip / yum install zip / brew install zip" }, + "tar.gz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "tar.bz2": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "tar.xz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "tar": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "7z": { cmd: "7z", install: "apt install p7zip-full / yum install p7zip / brew install p7zip" }, + }; + + const info = commandMap[compressionFormat]; + if (info) { + friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`; + } + } + + return res.status(500).json({ error: friendlyError }); + } + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + }); + + res.json({ + success: true, + message: "Files compressed successfully", + archivePath: archivePath + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH compressFiles stream error:", streamErr, { + operation: "compress_files", + sessionId, + paths, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while compressing files" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3637167e..6d4fe887 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -907,6 +907,18 @@ "extractingArchive": "Extracting {{name}}...", "archiveExtractedSuccessfully": "{{name}} extracted successfully", "extractFailed": "Extract failed", + "compressFile": "Compress File", + "compressFiles": "Compress Files", + "compressFilesDesc": "Compress {{count}} items into an archive", + "archiveName": "Archive Name", + "enterArchiveName": "Enter archive name...", + "compressionFormat": "Compression Format", + "selectedFiles": "Selected files", + "andMoreFiles": "and {{count}} more...", + "compress": "Compress", + "compressingFiles": "Compressing {{count}} items into {{name}}...", + "filesCompressedSuccessfully": "{{name}} created successfully", + "compressFailed": "Compression failed", "edit": "Edit", "preview": "Preview", "previous": "Previous", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 665c2626..615d1230 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -919,6 +919,18 @@ "extractingArchive": "正在解压 {{name}}...", "archiveExtractedSuccessfully": "{{name}} 解压成功", "extractFailed": "解压失败", + "compressFile": "压缩文件", + "compressFiles": "压缩文件", + "compressFilesDesc": "将 {{count}} 个项目压缩为归档文件", + "archiveName": "归档文件名", + "enterArchiveName": "输入归档文件名...", + "compressionFormat": "压缩格式", + "selectedFiles": "已选文件", + "andMoreFiles": "以及其他 {{count}} 个...", + "compress": "压缩", + "compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...", + "filesCompressedSuccessfully": "{{name}} 创建成功", + "compressFailed": "压缩失败", "edit": "编辑", "preview": "预览", "previous": "上一页", diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 6d981f76..8cab1ec6 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { PermissionsDialog } from "./components/PermissionsDialog"; +import { CompressDialog } from "./components/CompressDialog"; import { Upload, FolderPlus, @@ -52,6 +53,7 @@ import { logActivity, changeSSHPermissions, extractSSHArchive, + compressSSHFiles, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -150,6 +152,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); const [permissionsDialogFile, setPermissionsDialogFile] = useState(null); + const [compressDialogFiles, setCompressDialogFiles] = useState([]); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); @@ -1090,6 +1093,48 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } + function handleOpenCompressDialog(files: FileItem[]) { + setCompressDialogFiles(files); + } + + async function handleCompress(archiveName: string, format: string) { + if (!sshSessionId || compressDialogFiles.length === 0) return; + + try { + await ensureSSHConnection(); + + const paths = compressDialogFiles.map(f => f.path); + const fileNames = compressDialogFiles.map(f => f.name); + + toast.info(t("fileManager.compressingFiles", { + count: fileNames.length, + name: archiveName + })); + + await compressSSHFiles( + sshSessionId, + paths, + archiveName, + format, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.filesCompressedSuccessfully", { + name: archiveName + })); + + // Refresh directory to show compressed file + handleRefreshDirectory(); + clearSelection(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + async function handleUndo() { if (undoHistory.length === 0) { toast.info(t("fileManager.noUndoableActions")); @@ -2030,10 +2075,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { currentPath={currentPath} onProperties={handleOpenPermissionsDialog} onExtractArchive={handleExtractArchive} + onCompress={handleOpenCompressDialog} /> + 0} + onOpenChange={(open) => !open && setCompressDialogFiles([])} + fileNames={compressDialogFiles.map(f => f.name)} + onCompress={handleCompress} + /> + boolean; currentPath?: string; onExtractArchive?: (file: FileItem) => void; + onCompress?: (files: FileItem[]) => void; } interface MenuItem { @@ -102,6 +103,7 @@ export function FileManagerContextMenu({ isPinned, currentPath, onExtractArchive, + onCompress, }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -284,6 +286,18 @@ export function FileManagerContextMenu({ } } + // Add compress option for selected files/folders + if (isFileContext && onCompress) { + menuItems.push({ + icon: , + label: isMultipleFiles + ? t("fileManager.compressFiles") + : t("fileManager.compressFile"), + action: () => onCompress(files), + shortcut: "Ctrl+Shift+C", + }); + } + if (isSingleFile && files[0].type === "file") { const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; diff --git a/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx b/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx new file mode 100644 index 00000000..5cf8ba3c --- /dev/null +++ b/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslation } from "react-i18next"; + +interface CompressDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + fileNames: string[]; + onCompress: (archiveName: string, format: string) => void; +} + +export function CompressDialog({ + open, + onOpenChange, + fileNames, + onCompress, +}: CompressDialogProps) { + const { t } = useTranslation(); + const [archiveName, setArchiveName] = useState(""); + const [format, setFormat] = useState("zip"); + + useEffect(() => { + if (open && fileNames.length > 0) { + // Generate default archive name + if (fileNames.length === 1) { + const baseName = fileNames[0].replace(/\.[^/.]+$/, ""); + setArchiveName(baseName); + } else { + setArchiveName("archive"); + } + } + }, [open, fileNames]); + + const handleCompress = () => { + if (!archiveName.trim()) return; + + // Append extension if not already present + let finalName = archiveName.trim(); + const extensions: Record = { + zip: ".zip", + "tar.gz": ".tar.gz", + "tar.bz2": ".tar.bz2", + "tar.xz": ".tar.xz", + tar: ".tar", + "7z": ".7z", + }; + + const expectedExtension = extensions[format]; + if (expectedExtension && !finalName.endsWith(expectedExtension)) { + finalName += expectedExtension; + } + + onCompress(finalName, format); + onOpenChange(false); + }; + + return ( + + + + {t("fileManager.compressFiles")} + + {t("fileManager.compressFilesDesc", { count: fileNames.length })} + + + +
+
+ + setArchiveName(e.target.value)} + placeholder={t("fileManager.enterArchiveName")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCompress(); + } + }} + /> +
+ +
+ + +
+ +
+

+ {t("fileManager.selectedFiles")}: +

+
    + {fileNames.slice(0, 5).map((name, index) => ( +
  • + • {name} +
  • + ))} + {fileNames.length > 5 && ( +
  • + {t("fileManager.andMoreFiles", { count: fileNames.length - 5 })} +
  • + )} +
+
+
+ + + + + +
+
+ ); +}