diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index b2ee2138..6ec53e3e 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2587,6 +2587,132 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { }); }); +// Route: Extract archive file (requires JWT) +// POST /ssh/file_manager/ssh/extractArchive +app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { + const { sessionId, archivePath, extractPath } = req.body; + + if (!sessionId || !archivePath) { + 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); + + const fileName = archivePath.split("/").pop() || ""; + const fileExt = fileName.toLowerCase(); + + // Determine extraction command based on file extension + let extractCommand = ""; + const targetPath = extractPath || archivePath.substring(0, archivePath.lastIndexOf("/")); + + if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) { + extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) { + extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar.xz")) { + extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar")) { + extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".zip")) { + extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`; + } else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) { + extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`; + } else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) { + extractCommand = `bunzip2 -k "${archivePath}"`; + } else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) { + extractCommand = `unxz -k "${archivePath}"`; + } else if (fileExt.endsWith(".7z")) { + extractCommand = `7z x "${archivePath}" -o"${targetPath}"`; + } else if (fileExt.endsWith(".rar")) { + extractCommand = `unrar x "${archivePath}" "${targetPath}/"`; + } else { + return res.status(400).json({ error: "Unsupported archive format" }); + } + + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: targetPath, + command: extractCommand, + }); + + session.client.exec(extractCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH exec error during extract:", err, { + operation: "extract_archive", + sessionId, + archivePath, + }); + return res.status(500).json({ error: "Failed to execute extract command" }); + } + + let errorOutput = ""; + + stream.on("data", (data: Buffer) => { + fileLogger.debug("Extract stdout", { + operation: "extract_archive", + sessionId, + output: data.toString(), + }); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + fileLogger.debug("Extract stderr", { + operation: "extract_archive", + sessionId, + error: data.toString(), + }); + }); + + stream.on("close", (code: number) => { + if (code !== 0) { + fileLogger.error("Extract command failed", { + operation: "extract_archive", + sessionId, + archivePath, + exitCode: code, + error: errorOutput, + }); + return res.status(500).json({ + error: errorOutput || "Failed to extract archive" + }); + } + + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: targetPath, + }); + + res.json({ + success: true, + message: "Archive extracted successfully", + extractPath: targetPath + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH extractArchive stream error:", streamErr, { + operation: "extract_archive", + sessionId, + archivePath, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while extracting archive" }); + } + }); + }); +}); + 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 51e96e4e..3637167e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -903,6 +903,10 @@ "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", "downloadFile": "Download", + "extractArchive": "Extract Archive", + "extractingArchive": "Extracting {{name}}...", + "archiveExtractedSuccessfully": "{{name}} extracted successfully", + "extractFailed": "Extract failed", "edit": "Edit", "preview": "Preview", "previous": "Previous", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 00202b57..665c2626 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -915,6 +915,10 @@ "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", "downloadFile": "下载", + "extractArchive": "解压文件", + "extractingArchive": "正在解压 {{name}}...", + "archiveExtractedSuccessfully": "{{name}} 解压成功", + "extractFailed": "解压失败", "edit": "编辑", "preview": "预览", "previous": "上一页", diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 7c0e8208..6d981f76 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -51,6 +51,7 @@ import { getPinnedFiles, logActivity, changeSSHPermissions, + extractSSHArchive, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -1061,6 +1062,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } + async function handleExtractArchive(file: FileItem) { + if (!sshSessionId) return; + + try { + await ensureSSHConnection(); + + toast.info(t("fileManager.extractingArchive", { name: file.name })); + + await extractSSHArchive( + sshSessionId, + file.path, + undefined, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.archiveExtractedSuccessfully", { name: file.name })); + + // Refresh directory to show extracted files + handleRefreshDirectory(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + async function handleUndo() { if (undoHistory.length === 0) { toast.info(t("fileManager.noUndoableActions")); @@ -2000,6 +2029,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { isPinned={isPinnedFile} currentPath={currentPath} onProperties={handleOpenPermissionsDialog} + onExtractArchive={handleExtractArchive} /> diff --git a/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx b/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx index e11cbf6c..e2fac138 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx @@ -17,6 +17,7 @@ import { Play, Star, Bookmark, + FileArchive, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; @@ -60,6 +61,7 @@ interface ContextMenuProps { onAddShortcut?: (path: string) => void; isPinned?: (file: FileItem) => boolean; currentPath?: string; + onExtractArchive?: (file: FileItem) => void; } interface MenuItem { @@ -99,6 +101,7 @@ export function FileManagerContextMenu({ onAddShortcut, isPinned, currentPath, + onExtractArchive, }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -254,6 +257,33 @@ export function FileManagerContextMenu({ }); } + // Add extract option for archive files + if (isSingleFile && files[0].type === "file" && onExtractArchive) { + const fileName = files[0].name.toLowerCase(); + const isArchive = + fileName.endsWith(".zip") || + fileName.endsWith(".tar") || + fileName.endsWith(".tar.gz") || + fileName.endsWith(".tgz") || + fileName.endsWith(".tar.bz2") || + fileName.endsWith(".tbz2") || + fileName.endsWith(".tar.xz") || + fileName.endsWith(".gz") || + fileName.endsWith(".bz2") || + fileName.endsWith(".xz") || + fileName.endsWith(".7z") || + fileName.endsWith(".rar"); + + if (isArchive) { + menuItems.push({ + icon: , + label: t("fileManager.extractArchive"), + action: () => onExtractArchive(files[0]), + shortcut: "Ctrl+E", + }); + } + } + if (isSingleFile && files[0].type === "file") { const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 7d9e6342..ae7fc925 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1561,6 +1561,51 @@ export async function changeSSHPermissions( } } +export async function extractSSHArchive( + sessionId: string, + archivePath: string, + extractPath?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; extractPath: string }> { + try { + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/extractArchive", { + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); + + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: response.data.extractPath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to extract archive", error, { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + }); + handleApiError(error, "extract archive"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================