diff --git a/package-lock.json b/package-lock.json
index 13c1b40..ef23dc7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,6 +43,7 @@
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
+ "jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
@@ -4697,6 +4698,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -6478,6 +6485,12 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-cwd": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",
@@ -6862,6 +6875,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6994,6 +7013,48 @@
"node": ">=10"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/jszip/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@@ -7049,6 +7110,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -9118,6 +9188,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -9518,6 +9594,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -10656,6 +10738,12 @@
"license": "ISC",
"optional": true
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
diff --git a/package.json b/package.json
index bf3770d..a48803f 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,7 @@
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
+ "jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
diff --git a/server/index.js b/server/index.js
index 352dd7e..7d64a9a 100755
--- a/server/index.js
+++ b/server/index.js
@@ -884,6 +884,436 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
}
});
+// ============================================================================
+// FILE OPERATIONS API ENDPOINTS
+// ============================================================================
+
+/**
+ * Validate that a path is within the project root
+ * @param {string} projectRoot - The project root path
+ * @param {string} targetPath - The path to validate
+ * @returns {{ valid: boolean, resolved?: string, error?: string }}
+ */
+function validatePathInProject(projectRoot, targetPath) {
+ const resolved = path.isAbsolute(targetPath)
+ ? path.resolve(targetPath)
+ : path.resolve(projectRoot, targetPath);
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
+ if (!resolved.startsWith(normalizedRoot)) {
+ return { valid: false, error: 'Path must be under project root' };
+ }
+ return { valid: true, resolved };
+}
+
+/**
+ * Validate filename - check for invalid characters
+ * @param {string} name - The filename to validate
+ * @returns {{ valid: boolean, error?: string }}
+ */
+function validateFilename(name) {
+ if (!name || !name.trim()) {
+ return { valid: false, error: 'Filename cannot be empty' };
+ }
+ // Check for invalid characters (Windows + Unix)
+ const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
+ if (invalidChars.test(name)) {
+ return { valid: false, error: 'Filename contains invalid characters' };
+ }
+ // Check for reserved names (Windows)
+ const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
+ if (reserved.test(name)) {
+ return { valid: false, error: 'Filename is a reserved name' };
+ }
+ // Check for dots only
+ if (/^\.+$/.test(name)) {
+ return { valid: false, error: 'Filename cannot be only dots' };
+ }
+ return { valid: true };
+}
+
+// POST /api/projects/:projectName/files/create - Create new file or directory
+app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
+ try {
+ const { projectName } = req.params;
+ const { path: parentPath, type, name } = req.body;
+
+ // Validate input
+ if (!name || !type) {
+ return res.status(400).json({ error: 'Name and type are required' });
+ }
+
+ if (!['file', 'directory'].includes(type)) {
+ return res.status(400).json({ error: 'Type must be "file" or "directory"' });
+ }
+
+ const nameValidation = validateFilename(name);
+ if (!nameValidation.valid) {
+ return res.status(400).json({ error: nameValidation.error });
+ }
+
+ // Get project root
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
+ if (!projectRoot) {
+ return res.status(404).json({ error: 'Project not found' });
+ }
+
+ // Build and validate target path
+ const targetDir = parentPath || '';
+ const targetPath = targetDir ? path.join(targetDir, name) : name;
+ const validation = validatePathInProject(projectRoot, targetPath);
+ if (!validation.valid) {
+ return res.status(403).json({ error: validation.error });
+ }
+
+ const resolvedPath = validation.resolved;
+
+ // Check if already exists
+ try {
+ await fsPromises.access(resolvedPath);
+ return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
+ } catch {
+ // Doesn't exist, which is what we want
+ }
+
+ // Create file or directory
+ if (type === 'directory') {
+ await fsPromises.mkdir(resolvedPath, { recursive: false });
+ } else {
+ // Ensure parent directory exists
+ const parentDir = path.dirname(resolvedPath);
+ try {
+ await fsPromises.access(parentDir);
+ } catch {
+ await fsPromises.mkdir(parentDir, { recursive: true });
+ }
+ await fsPromises.writeFile(resolvedPath, '', 'utf8');
+ }
+
+ res.json({
+ success: true,
+ path: resolvedPath,
+ name,
+ type,
+ message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
+ });
+ } catch (error) {
+ console.error('Error creating file/directory:', error);
+ if (error.code === 'EACCES') {
+ res.status(403).json({ error: 'Permission denied' });
+ } else if (error.code === 'ENOENT') {
+ res.status(404).json({ error: 'Parent directory not found' });
+ } else {
+ res.status(500).json({ error: error.message });
+ }
+ }
+});
+
+// PUT /api/projects/:projectName/files/rename - Rename file or directory
+app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
+ try {
+ const { projectName } = req.params;
+ const { oldPath, newName } = req.body;
+
+ // Validate input
+ if (!oldPath || !newName) {
+ return res.status(400).json({ error: 'oldPath and newName are required' });
+ }
+
+ const nameValidation = validateFilename(newName);
+ if (!nameValidation.valid) {
+ return res.status(400).json({ error: nameValidation.error });
+ }
+
+ // Get project root
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
+ if (!projectRoot) {
+ return res.status(404).json({ error: 'Project not found' });
+ }
+
+ // Validate old path
+ const oldValidation = validatePathInProject(projectRoot, oldPath);
+ if (!oldValidation.valid) {
+ return res.status(403).json({ error: oldValidation.error });
+ }
+
+ const resolvedOldPath = oldValidation.resolved;
+
+ // Check if old path exists
+ try {
+ await fsPromises.access(resolvedOldPath);
+ } catch {
+ return res.status(404).json({ error: 'File or directory not found' });
+ }
+
+ // Build and validate new path
+ const parentDir = path.dirname(resolvedOldPath);
+ const resolvedNewPath = path.join(parentDir, newName);
+ const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
+ if (!newValidation.valid) {
+ return res.status(403).json({ error: newValidation.error });
+ }
+
+ // Check if new path already exists
+ try {
+ await fsPromises.access(resolvedNewPath);
+ return res.status(409).json({ error: 'A file or directory with this name already exists' });
+ } catch {
+ // Doesn't exist, which is what we want
+ }
+
+ // Rename
+ await fsPromises.rename(resolvedOldPath, resolvedNewPath);
+
+ res.json({
+ success: true,
+ oldPath: resolvedOldPath,
+ newPath: resolvedNewPath,
+ newName,
+ message: 'Renamed successfully'
+ });
+ } catch (error) {
+ console.error('Error renaming file/directory:', error);
+ if (error.code === 'EACCES') {
+ res.status(403).json({ error: 'Permission denied' });
+ } else if (error.code === 'ENOENT') {
+ res.status(404).json({ error: 'File or directory not found' });
+ } else if (error.code === 'EXDEV') {
+ res.status(400).json({ error: 'Cannot move across different filesystems' });
+ } else {
+ res.status(500).json({ error: error.message });
+ }
+ }
+});
+
+// DELETE /api/projects/:projectName/files - Delete file or directory
+app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
+ try {
+ const { projectName } = req.params;
+ const { path: targetPath, type } = req.body;
+
+ // Validate input
+ if (!targetPath) {
+ return res.status(400).json({ error: 'Path is required' });
+ }
+
+ // Get project root
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
+ if (!projectRoot) {
+ return res.status(404).json({ error: 'Project not found' });
+ }
+
+ // Validate path
+ const validation = validatePathInProject(projectRoot, targetPath);
+ if (!validation.valid) {
+ return res.status(403).json({ error: validation.error });
+ }
+
+ const resolvedPath = validation.resolved;
+
+ // Check if path exists and get stats
+ let stats;
+ try {
+ stats = await fsPromises.stat(resolvedPath);
+ } catch {
+ return res.status(404).json({ error: 'File or directory not found' });
+ }
+
+ // Prevent deleting the project root itself
+ if (resolvedPath === path.resolve(projectRoot)) {
+ return res.status(403).json({ error: 'Cannot delete project root directory' });
+ }
+
+ // Delete based on type
+ if (stats.isDirectory()) {
+ await fsPromises.rm(resolvedPath, { recursive: true, force: true });
+ } else {
+ await fsPromises.unlink(resolvedPath);
+ }
+
+ res.json({
+ success: true,
+ path: resolvedPath,
+ type: stats.isDirectory() ? 'directory' : 'file',
+ message: 'Deleted successfully'
+ });
+ } catch (error) {
+ console.error('Error deleting file/directory:', error);
+ if (error.code === 'EACCES') {
+ res.status(403).json({ error: 'Permission denied' });
+ } else if (error.code === 'ENOENT') {
+ res.status(404).json({ error: 'File or directory not found' });
+ } else if (error.code === 'ENOTEMPTY') {
+ res.status(400).json({ error: 'Directory is not empty' });
+ } else {
+ res.status(500).json({ error: error.message });
+ }
+ }
+});
+
+// POST /api/projects/:projectName/files/upload - Upload files
+// Dynamic import of multer for file uploads
+const uploadFilesHandler = async (req, res) => {
+ // Dynamic import of multer
+ const multer = (await import('multer')).default;
+
+ const uploadMiddleware = multer({
+ storage: multer.diskStorage({
+ destination: (req, file, cb) => {
+ cb(null, os.tmpdir());
+ },
+ filename: (req, file, cb) => {
+ // Use a unique temp name, but preserve original name in file.originalname
+ // Note: file.originalname may contain path separators for folder uploads
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
+ // For temp file, just use a safe unique name without the path
+ cb(null, `upload-${uniqueSuffix}`);
+ }
+ }),
+ limits: {
+ fileSize: 50 * 1024 * 1024, // 50MB limit
+ files: 20 // Max 20 files at once
+ }
+ });
+
+ // Use multer middleware
+ uploadMiddleware.array('files', 20)(req, res, async (err) => {
+ if (err) {
+ console.error('Multer error:', err);
+ if (err.code === 'LIMIT_FILE_SIZE') {
+ return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
+ }
+ if (err.code === 'LIMIT_FILE_COUNT') {
+ return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
+ }
+ return res.status(500).json({ error: err.message });
+ }
+
+ try {
+ const { projectName } = req.params;
+ const { targetPath, relativePaths } = req.body;
+
+ // Parse relative paths if provided (for folder uploads)
+ let filePaths = [];
+ if (relativePaths) {
+ try {
+ filePaths = JSON.parse(relativePaths);
+ } catch (e) {
+ console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
+ }
+ }
+
+ console.log('[DEBUG] File upload request:', {
+ projectName,
+ targetPath: JSON.stringify(targetPath),
+ targetPathType: typeof targetPath,
+ filesCount: req.files?.length,
+ relativePaths: filePaths
+ });
+
+ if (!req.files || req.files.length === 0) {
+ return res.status(400).json({ error: 'No files provided' });
+ }
+
+ // Get project root
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
+ if (!projectRoot) {
+ return res.status(404).json({ error: 'Project not found' });
+ }
+
+ console.log('[DEBUG] Project root:', projectRoot);
+
+ // Validate and resolve target path
+ // If targetPath is empty or '.', use project root directly
+ const targetDir = targetPath || '';
+ let resolvedTargetDir;
+
+ console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
+
+ if (!targetDir || targetDir === '.' || targetDir === './') {
+ // Empty path means upload to project root
+ resolvedTargetDir = path.resolve(projectRoot);
+ console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
+ } else {
+ const validation = validatePathInProject(projectRoot, targetDir);
+ if (!validation.valid) {
+ console.log('[DEBUG] Path validation failed:', validation.error);
+ return res.status(403).json({ error: validation.error });
+ }
+ resolvedTargetDir = validation.resolved;
+ console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
+ }
+
+ // Ensure target directory exists
+ try {
+ await fsPromises.access(resolvedTargetDir);
+ } catch {
+ await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
+ }
+
+ // Move uploaded files from temp to target directory
+ const uploadedFiles = [];
+ console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
+ for (let i = 0; i < req.files.length; i++) {
+ const file = req.files[i];
+ // Use relative path if provided (for folder uploads), otherwise use originalname
+ const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
+ console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
+ const destPath = path.join(resolvedTargetDir, fileName);
+
+ // Validate destination path
+ const destValidation = validatePathInProject(projectRoot, destPath);
+ if (!destValidation.valid) {
+ console.log('[DEBUG] Destination validation failed for:', destPath);
+ // Clean up temp file
+ await fsPromises.unlink(file.path).catch(() => {});
+ continue;
+ }
+
+ // Ensure parent directory exists (for nested files from folder upload)
+ const parentDir = path.dirname(destPath);
+ try {
+ await fsPromises.access(parentDir);
+ } catch {
+ await fsPromises.mkdir(parentDir, { recursive: true });
+ }
+
+ // Move file (copy + unlink to handle cross-device scenarios)
+ await fsPromises.copyFile(file.path, destPath);
+ await fsPromises.unlink(file.path);
+
+ uploadedFiles.push({
+ name: fileName,
+ path: destPath,
+ size: file.size,
+ mimeType: file.mimetype
+ });
+ }
+
+ res.json({
+ success: true,
+ files: uploadedFiles,
+ targetPath: resolvedTargetDir,
+ message: `Uploaded ${uploadedFiles.length} file(s) successfully`
+ });
+ } catch (error) {
+ console.error('Error uploading files:', error);
+ // Clean up any remaining temp files
+ if (req.files) {
+ for (const file of req.files) {
+ await fsPromises.unlink(file.path).catch(() => {});
+ }
+ }
+ if (error.code === 'EACCES') {
+ res.status(403).json({ error: 'Permission denied' });
+ } else {
+ res.status(500).json({ error: error.message });
+ }
+ }
+ });
+};
+
+app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
+
// WebSocket connection handler that routes based on URL path
wss.on('connection', (ws, request) => {
const url = request.url;
diff --git a/src/components/FileContextMenu.jsx b/src/components/FileContextMenu.jsx
new file mode 100644
index 0000000..5fee1ef
--- /dev/null
+++ b/src/components/FileContextMenu.jsx
@@ -0,0 +1,312 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ FileText,
+ FolderPlus,
+ Pencil,
+ Trash2,
+ Copy,
+ Download,
+ RefreshCw
+} from 'lucide-react';
+import { cn } from '../lib/utils';
+
+/**
+ * FileContextMenu Component
+ * Right-click context menu for file/directory operations
+ */
+const FileContextMenu = ({
+ children,
+ item,
+ onRename,
+ onDelete,
+ onNewFile,
+ onNewFolder,
+ onRefresh,
+ onCopyPath,
+ onDownload,
+ isLoading = false,
+ className = ''
+}) => {
+ const { t } = useTranslation();
+ const [isOpen, setIsOpen] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const menuRef = useRef(null);
+ const triggerRef = useRef(null);
+
+ const isDirectory = item?.type === 'directory';
+ const isFile = item?.type === 'file';
+ const isBackground = !item; // Clicked on empty space
+
+ // Handle right-click
+ const handleContextMenu = useCallback((e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = e.clientX;
+ const y = e.clientY;
+
+ // Adjust position if menu would go off screen
+ const menuWidth = 200;
+ const menuHeight = 300;
+
+ let adjustedX = x;
+ let adjustedY = y;
+
+ if (x + menuWidth > window.innerWidth) {
+ adjustedX = window.innerWidth - menuWidth - 10;
+ }
+ if (y + menuHeight > window.innerHeight) {
+ adjustedY = window.innerHeight - menuHeight - 10;
+ }
+
+ setPosition({ x: adjustedX, y: adjustedY });
+ setIsOpen(true);
+ }, []);
+
+ // Close menu
+ const closeMenu = useCallback(() => {
+ setIsOpen(false);
+ }, []);
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
+ closeMenu();
+ }
+ };
+
+ const handleEscape = (e) => {
+ if (e.key === 'Escape') {
+ closeMenu();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('keydown', handleEscape);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('keydown', handleEscape);
+ };
+ }, [isOpen, closeMenu]);
+
+ // Handle keyboard navigation
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (e) => {
+ const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
+ if (!menuItems || menuItems.length === 0) return;
+
+ const currentIndex = Array.from(menuItems).findIndex(
+ (item) => item === document.activeElement
+ );
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
+ menuItems[nextIndex]?.focus();
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
+ menuItems[prevIndex]?.focus();
+ break;
+ case 'Enter':
+ case ' ':
+ if (document.activeElement?.hasAttribute('role', 'menuitem')) {
+ e.preventDefault();
+ document.activeElement.click();
+ }
+ break;
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen]);
+
+ // Handle action click
+ const handleAction = (action, ...args) => {
+ closeMenu();
+ action?.(...args);
+ };
+
+ // Menu item component
+ const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => (
+
+ );
+
+ // Menu divider
+ const MenuDivider = () => (
+
+ );
+
+ // Build menu items based on context
+ const renderMenuItems = () => {
+ if (isFile) {
+ return (
+ <>
+