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:
ZacharyZcR
2025-11-09 17:56:08 +08:00
parent 303386806f
commit c4c5be34f2
6 changed files with 239 additions and 0 deletions

View File

@@ -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);

View File

@@ -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",

View File

@@ -915,6 +915,10 @@
"connectToSsh": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"downloadFile": "下载",
"extractArchive": "解压文件",
"extractingArchive": "正在解压 {{name}}...",
"archiveExtractedSuccessfully": "{{name}} 解压成功",
"extractFailed": "解压失败",
"edit": "编辑",
"preview": "预览",
"previous": "上一页",

View File

@@ -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>

View File

@@ -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;

View File

@@ -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
// ============================================================================