feat: Add archive extraction feature to file manager
Adds comprehensive archive extraction support to the file manager context menu: Backend API (file-manager.ts): - New POST /ssh/file_manager/ssh/extractArchive endpoint - Supports multiple archive formats: .zip, .tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz, .gz, .bz2, .xz, .7z, .rar - Automatically selects appropriate extraction command based on file extension - Extracts to current directory by default, supports custom extraction path - Proper error handling and logging for extraction operations Frontend Implementation: - Added extractSSHArchive() function in main-axios.ts for API calls - Added handleExtractArchive() handler in FileManager.tsx - FileManagerContextMenu displays 'Extract Archive' option for supported archive files - FileArchive icon from lucide-react for visual clarity - Keyboard shortcut: Ctrl+E User Experience: - Right-click on any supported archive file to see 'Extract Archive' option - Toast notifications for progress and success/error states - Automatic directory refresh after extraction to show extracted files - Only shows extract option for recognized archive file types i18n Support: - English translations: extractArchive, extractingArchive, archiveExtractedSuccessfully, extractFailed - Chinese translations: 解压文件, 正在解压, 解压成功, 解压失败 Supported Archive Formats: - ZIP archives (.zip) - TAR archives (.tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz) - Compressed files (.gz, .bz2, .xz) - 7-Zip archives (.7z) - RAR archives (.rar) This feature streamlines file management workflows by allowing users to extract archives directly from the file manager without switching to terminal.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -915,6 +915,10 @@
|
||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||
"uploadFile": "上传文件",
|
||||
"downloadFile": "下载",
|
||||
"extractArchive": "解压文件",
|
||||
"extractingArchive": "正在解压 {{name}}...",
|
||||
"archiveExtractedSuccessfully": "{{name}} 解压成功",
|
||||
"extractFailed": "解压失败",
|
||||
"edit": "编辑",
|
||||
"preview": "预览",
|
||||
"previous": "上一页",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: <FileArchive className="w-4 h-4" />,
|
||||
label: t("fileManager.extractArchive"),
|
||||
action: () => onExtractArchive(files[0]),
|
||||
shortcut: "Ctrl+E",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file") {
|
||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user