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", () => {
|
process.on("SIGINT", () => {
|
||||||
Object.keys(sshSessions).forEach(cleanupSession);
|
Object.keys(sshSessions).forEach(cleanupSession);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -903,6 +903,10 @@
|
|||||||
"connectToSsh": "Connect to SSH to use file operations",
|
"connectToSsh": "Connect to SSH to use file operations",
|
||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
"downloadFile": "Download",
|
"downloadFile": "Download",
|
||||||
|
"extractArchive": "Extract Archive",
|
||||||
|
"extractingArchive": "Extracting {{name}}...",
|
||||||
|
"archiveExtractedSuccessfully": "{{name}} extracted successfully",
|
||||||
|
"extractFailed": "Extract failed",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
|
|||||||
@@ -915,6 +915,10 @@
|
|||||||
"connectToSsh": "连接 SSH 以使用文件操作",
|
"connectToSsh": "连接 SSH 以使用文件操作",
|
||||||
"uploadFile": "上传文件",
|
"uploadFile": "上传文件",
|
||||||
"downloadFile": "下载",
|
"downloadFile": "下载",
|
||||||
|
"extractArchive": "解压文件",
|
||||||
|
"extractingArchive": "正在解压 {{name}}...",
|
||||||
|
"archiveExtractedSuccessfully": "{{name}} 解压成功",
|
||||||
|
"extractFailed": "解压失败",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"previous": "上一页",
|
"previous": "上一页",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
getPinnedFiles,
|
getPinnedFiles,
|
||||||
logActivity,
|
logActivity,
|
||||||
changeSSHPermissions,
|
changeSSHPermissions,
|
||||||
|
extractSSHArchive,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { SidebarItem } from "./FileManagerSidebar";
|
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() {
|
async function handleUndo() {
|
||||||
if (undoHistory.length === 0) {
|
if (undoHistory.length === 0) {
|
||||||
toast.info(t("fileManager.noUndoableActions"));
|
toast.info(t("fileManager.noUndoableActions"));
|
||||||
@@ -2000,6 +2029,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
isPinned={isPinnedFile}
|
isPinned={isPinnedFile}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
onProperties={handleOpenPermissionsDialog}
|
onProperties={handleOpenPermissionsDialog}
|
||||||
|
onExtractArchive={handleExtractArchive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Star,
|
Star,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
FileArchive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||||
@@ -60,6 +61,7 @@ interface ContextMenuProps {
|
|||||||
onAddShortcut?: (path: string) => void;
|
onAddShortcut?: (path: string) => void;
|
||||||
isPinned?: (file: FileItem) => boolean;
|
isPinned?: (file: FileItem) => boolean;
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
|
onExtractArchive?: (file: FileItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -99,6 +101,7 @@ export function FileManagerContextMenu({
|
|||||||
onAddShortcut,
|
onAddShortcut,
|
||||||
isPinned,
|
isPinned,
|
||||||
currentPath,
|
currentPath,
|
||||||
|
onExtractArchive,
|
||||||
}: ContextMenuProps) {
|
}: ContextMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
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") {
|
if (isSingleFile && files[0].type === "file") {
|
||||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
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
|
// FILE MANAGER DATA
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user