From 97689588aa2e8240ba4373da5f42ab444c772e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E8=A7=81?= <531301071@qq.com> Date: Tue, 3 Mar 2026 20:19:46 +0800 Subject: [PATCH] feat: Advanced file editor and file tree improvements (#444) # Features - File drag and drop upload: Support uploading files and folders via drag and drop - Binary file handling: Detect binary files and display a friendly message instead of trying to edit them - Folder download: Download folders as ZIP files (using JSZip library) - Context menu integration: Full right-click context menu for file operations (rename, delete, copy path, download, new file/folder) --- package-lock.json | 88 ++++ package.json | 1 + server/index.js | 430 ++++++++++++++++++ src/components/FileContextMenu.jsx | 312 +++++++++++++ .../hooks/useCodeEditorDocument.ts | 13 +- .../code-editor/hooks/useEditorSidebar.ts | 9 +- .../code-editor/utils/binaryFile.ts | 22 + .../code-editor/view/CodeEditor.tsx | 17 + .../code-editor/view/EditorSidebar.tsx | 58 ++- .../subcomponents/CodeEditorBinaryFile.tsx | 115 +++++ .../view/subcomponents/CodeEditorHeader.tsx | 24 +- .../file-tree/hooks/useExpandedDirectories.ts | 6 + .../file-tree/hooks/useFileTreeData.ts | 23 +- .../file-tree/hooks/useFileTreeOperations.ts | 382 ++++++++++++++++ .../file-tree/hooks/useFileTreeUpload.ts | 205 +++++++++ src/components/file-tree/view/FileTree.tsx | 221 ++++++++- .../file-tree/view/FileTreeBody.tsx | 51 ++- .../file-tree/view/FileTreeHeader.tsx | 81 +++- .../file-tree/view/FileTreeList.tsx | 46 +- .../file-tree/view/FileTreeNode.tsx | 173 +++++-- .../main-content/view/MainContent.tsx | 2 +- src/i18n/locales/en/codeEditor.json | 4 + src/i18n/locales/en/common.json | 6 +- src/i18n/locales/ja/codeEditor.json | 4 + src/i18n/locales/ja/common.json | 6 +- src/i18n/locales/ko/codeEditor.json | 4 + src/i18n/locales/ko/common.json | 6 +- src/i18n/locales/zh-CN/codeEditor.json | 4 + src/i18n/locales/zh-CN/common.json | 6 +- src/utils/api.js | 45 ++ 30 files changed, 2270 insertions(+), 94 deletions(-) create mode 100644 src/components/FileContextMenu.jsx create mode 100644 src/components/code-editor/utils/binaryFile.ts create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorBinaryFile.tsx create mode 100644 src/components/file-tree/hooks/useFileTreeOperations.ts create mode 100644 src/components/file-tree/hooks/useFileTreeUpload.ts 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 ( + <> + onRename?.(item)} + /> + onDelete?.(item)} + danger + /> + + onCopyPath?.(item)} + /> + onDownload?.(item)} + /> + + ); + } + + if (isDirectory) { + return ( + <> + onNewFile?.(item.path)} + /> + onNewFolder?.(item.path)} + /> + + onRename?.(item)} + /> + onDelete?.(item)} + danger + /> + + onCopyPath?.(item)} + /> + onDownload?.(item)} + /> + + ); + } + + // Background context (empty space) + return ( + <> + onNewFile?.('')} + /> + onNewFolder?.('')} + /> + + + + ); + }; + + return ( + <> + {/* Trigger element */} +
+ {children} +
+ + {/* Context menu portal */} + {isOpen && ( +
+ {isLoading ? ( +
+ + + {t('fileTree.context.loading', 'Loading...')} + +
+ ) : ( + renderMenuItems() + )} +
+ )} + + ); +}; + +export default FileContextMenu; diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts index 63fb7a0..5e3adc3 100644 --- a/src/components/code-editor/hooks/useCodeEditorDocument.ts +++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '../../../utils/api'; import type { CodeEditorFile } from '../types/types'; +import { isBinaryFile } from '../utils/binaryFile'; type UseCodeEditorDocumentParams = { file: CodeEditorFile; @@ -21,6 +22,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume const [saving, setSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [saveError, setSaveError] = useState(null); + const [isBinary, setIsBinary] = useState(false); const fileProjectName = file.projectName ?? projectPath; const filePath = file.path; const fileName = file.name; @@ -31,6 +33,14 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume const loadFileContent = async () => { try { setLoading(true); + setIsBinary(false); + + // Check if file is binary by extension + if (isBinaryFile(file.name)) { + setIsBinary(true); + setLoading(false); + return; + } // Diff payload may already include full old/new snapshots, so avoid disk read. if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) { @@ -60,7 +70,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume }; loadFileContent(); - }, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]); + }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]); const handleSave = useCallback(async () => { setSaving(true); @@ -120,6 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume saving, saveSuccess, saveError, + isBinary, handleSave, handleDownload, }; diff --git a/src/components/code-editor/hooks/useEditorSidebar.ts b/src/components/code-editor/hooks/useEditorSidebar.ts index bd70edf..d5a650b 100644 --- a/src/components/code-editor/hooks/useEditorSidebar.ts +++ b/src/components/code-editor/hooks/useEditorSidebar.ts @@ -65,12 +65,15 @@ export const useEditorSidebar = ({ return; } - const container = resizeHandleRef.current?.parentElement; - if (!container) { + // Get the main container (parent of EditorSidebar's parent) that contains both left content and editor + const editorContainer = resizeHandleRef.current?.parentElement; + const mainContainer = editorContainer?.parentElement; + if (!mainContainer) { return; } - const containerRect = container.getBoundingClientRect(); + const containerRect = mainContainer.getBoundingClientRect(); + // Calculate new editor width: distance from mouse to right edge of main container const newWidth = containerRect.right - event.clientX; const minWidth = 300; diff --git a/src/components/code-editor/utils/binaryFile.ts b/src/components/code-editor/utils/binaryFile.ts new file mode 100644 index 0000000..f03205c --- /dev/null +++ b/src/components/code-editor/utils/binaryFile.ts @@ -0,0 +1,22 @@ +// Binary file extensions (images are handled by ImageViewer, not here) +const BINARY_EXTENSIONS = [ + // Archives + 'zip', 'tar', 'gz', 'rar', '7z', 'bz2', 'xz', + // Executables + 'exe', 'dll', 'so', 'dylib', 'app', 'dmg', 'msi', + // Media + 'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4a', 'ogg', + // Documents + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', + // Fonts + 'ttf', 'otf', 'woff', 'woff2', 'eot', + // Database + 'db', 'sqlite', 'sqlite3', + // Other binary + 'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo' +]; + +export const isBinaryFile = (filename: string): boolean => { + const ext = filename.split('.').pop()?.toLowerCase(); + return BINARY_EXTENSIONS.includes(ext ?? ''); +}; diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx index c1f77f8..409ac33 100644 --- a/src/components/code-editor/view/CodeEditor.tsx +++ b/src/components/code-editor/view/CodeEditor.tsx @@ -14,6 +14,7 @@ import CodeEditorFooter from './subcomponents/CodeEditorFooter'; import CodeEditorHeader from './subcomponents/CodeEditorHeader'; import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; import CodeEditorSurface from './subcomponents/CodeEditorSurface'; +import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile'; type CodeEditorProps = { file: CodeEditorFile; @@ -54,6 +55,7 @@ export default function CodeEditor({ saving, saveSuccess, saveError, + isBinary, handleSave, handleDownload, } = useCodeEditorDocument({ @@ -158,6 +160,21 @@ export default function CodeEditor({ ); } + // Binary file display + if (isBinary) { + return ( + setIsFullscreen((previous) => !previous)} + title={t('binaryFile.title', 'Binary File')} + message={t('binaryFile.message', 'The file "{{fileName}}" cannot be displayed in the text editor because it is a binary file.', { fileName: file.name })} + /> + ); + } + const outerContainerClassName = isSidebar ? 'w-full h-full flex flex-col' : `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`; diff --git a/src/components/code-editor/view/EditorSidebar.tsx b/src/components/code-editor/view/EditorSidebar.tsx index d08e2b0..75b872d 100644 --- a/src/components/code-editor/view/EditorSidebar.tsx +++ b/src/components/code-editor/view/EditorSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { MouseEvent, MutableRefObject } from 'react'; import type { CodeEditorFile } from '../types/types'; import CodeEditor from './CodeEditor'; @@ -17,6 +17,11 @@ type EditorSidebarProps = { fillSpace?: boolean; }; +// Minimum width for the left content (file tree, chat, etc.) +const MIN_LEFT_CONTENT_WIDTH = 200; +// Minimum width for the editor sidebar +const MIN_EDITOR_WIDTH = 280; + export default function EditorSidebar({ editingFile, isMobile, @@ -31,6 +36,49 @@ export default function EditorSidebar({ fillSpace, }: EditorSidebarProps) { const [poppedOut, setPoppedOut] = useState(false); + const containerRef = useRef(null); + const [effectiveWidth, setEffectiveWidth] = useState(editorWidth); + + // Adjust editor width when container size changes to ensure buttons are always visible + useEffect(() => { + if (!editingFile || isMobile || poppedOut) return; + + const updateWidth = () => { + if (!containerRef.current) return; + const parentElement = containerRef.current.parentElement; + if (!parentElement) return; + + const containerWidth = parentElement.clientWidth; + + // Calculate maximum allowed editor width + const maxEditorWidth = containerWidth - MIN_LEFT_CONTENT_WIDTH; + + if (maxEditorWidth < MIN_EDITOR_WIDTH) { + // Not enough space - pop out the editor so user can still see everything + setPoppedOut(true); + } else if (editorWidth > maxEditorWidth) { + // Editor is too wide - constrain it to ensure left content has space + setEffectiveWidth(maxEditorWidth); + } else { + setEffectiveWidth(editorWidth); + } + }; + + updateWidth(); + window.addEventListener('resize', updateWidth); + + // Also use ResizeObserver for more accurate detection + const resizeObserver = new ResizeObserver(updateWidth); + const parentEl = containerRef.current?.parentElement; + if (parentEl) { + resizeObserver.observe(parentEl); + } + + return () => { + window.removeEventListener('resize', updateWidth); + resizeObserver.disconnect(); + }; + }, [editingFile, isMobile, poppedOut, editorWidth]); if (!editingFile) { return null; @@ -54,7 +102,7 @@ export default function EditorSidebar({ const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth); return ( - <> +
{!editorExpanded && (
setPoppedOut(true)} />
- +
); } diff --git a/src/components/code-editor/view/subcomponents/CodeEditorBinaryFile.tsx b/src/components/code-editor/view/subcomponents/CodeEditorBinaryFile.tsx new file mode 100644 index 0000000..57ab85a --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorBinaryFile.tsx @@ -0,0 +1,115 @@ +import type { CodeEditorFile } from '../../types/types'; + +type CodeEditorBinaryFileProps = { + file: CodeEditorFile; + isSidebar: boolean; + isFullscreen: boolean; + onClose: () => void; + onToggleFullscreen: () => void; + title: string; + message: string; +}; + +export default function CodeEditorBinaryFile({ + file, + isSidebar, + isFullscreen, + onClose, + onToggleFullscreen, + title, + message, +}: CodeEditorBinaryFileProps) { + const binaryContent = ( +
+
+
+ + + +
+
+

{title}

+

{message}

+
+ +
+
+ ); + + if (isSidebar) { + return ( +
+
+
+

{file.name}

+
+ +
+ {binaryContent} +
+ ); + } + + const containerClassName = isFullscreen + ? 'fixed inset-0 z-[9999] bg-background flex flex-col' + : 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'; + + const innerClassName = isFullscreen + ? 'bg-background flex flex-col w-full h-full' + : 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-2xl md:h-auto md:max-h-[60vh]'; + + return ( +
+
+
+
+

{file.name}

+
+
+ + +
+
+ {binaryContent} +
+
+ ); +} diff --git a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx index 41c7d21..4594234 100644 --- a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx +++ b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx @@ -49,13 +49,14 @@ export default function CodeEditorHeader({ const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save; return ( -
-
-
+
+ {/* File info - can shrink */} +
+

{file.name}

{file.diffInfo && ( - + {labels.showingChanges} )} @@ -64,12 +65,13 @@ export default function CodeEditorHeader({
-
+ {/* Buttons - don't shrink, always visible */} +
{isMarkdownFile && ( + +
+
+
+ )} + + {/* Toast Notification */} + {toast && ( +
+ {toast.type === 'success' ? ( + + ) : ( + + )} + {toast.message} +
+ )}
); } diff --git a/src/components/file-tree/view/FileTreeBody.tsx b/src/components/file-tree/view/FileTreeBody.tsx index dd59585..2793354 100644 --- a/src/components/file-tree/view/FileTreeBody.tsx +++ b/src/components/file-tree/view/FileTreeBody.tsx @@ -1,7 +1,6 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { Folder, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { ScrollArea } from '../../ui/scroll-area'; import type { FileTreeNode, FileTreeViewMode } from '../types/types'; import FileTreeEmptyState from './FileTreeEmptyState'; import FileTreeList from './FileTreeList'; @@ -16,6 +15,21 @@ type FileTreeBodyProps = { renderFileIcon: (filename: string) => ReactNode; formatFileSize: (bytes?: number) => string; formatRelativeTime: (date?: string) => string; + onRename?: (item: FileTreeNode) => void; + onDelete?: (item: FileTreeNode) => void; + onNewFile?: (path: string) => void; + onNewFolder?: (path: string) => void; + onCopyPath?: (item: FileTreeNode) => void; + onDownload?: (item: FileTreeNode) => void; + onRefresh?: () => void; + // Rename state for inline editing + renamingItem?: FileTreeNode | null; + renameValue?: string; + setRenameValue?: (value: string) => void; + handleConfirmRename?: () => void; + handleCancelRename?: () => void; + renameInputRef?: RefObject; + operationLoading?: boolean; }; export default function FileTreeBody({ @@ -28,11 +42,25 @@ export default function FileTreeBody({ renderFileIcon, formatFileSize, formatRelativeTime, + onRename, + onDelete, + onNewFile, + onNewFolder, + onCopyPath, + onDownload, + onRefresh, + renamingItem, + renameValue, + setRenameValue, + handleConfirmRename, + handleCancelRename, + renameInputRef, + operationLoading, }: FileTreeBodyProps) { const { t } = useTranslation(); return ( - + <> {files.length === 0 ? ( )} - + ); } - diff --git a/src/components/file-tree/view/FileTreeHeader.tsx b/src/components/file-tree/view/FileTreeHeader.tsx index 04d3124..ee72b38 100644 --- a/src/components/file-tree/view/FileTreeHeader.tsx +++ b/src/components/file-tree/view/FileTreeHeader.tsx @@ -1,7 +1,8 @@ -import { Eye, List, Search, TableProperties, X } from 'lucide-react'; +import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../ui/button'; import { Input } from '../../ui/input'; +import { cn } from '../../../lib/utils'; import type { FileTreeViewMode } from '../types/types'; type FileTreeHeaderProps = { @@ -9,6 +10,14 @@ type FileTreeHeaderProps = { onViewModeChange: (mode: FileTreeViewMode) => void; searchQuery: string; onSearchQueryChange: (query: string) => void; + // Toolbar actions + onNewFile?: () => void; + onNewFolder?: () => void; + onRefresh?: () => void; + onCollapseAll?: () => void; + // Loading state + loading?: boolean; + operationLoading?: boolean; }; export default function FileTreeHeader({ @@ -16,20 +25,83 @@ export default function FileTreeHeader({ onViewModeChange, searchQuery, onSearchQueryChange, + onNewFile, + onNewFolder, + onRefresh, + onCollapseAll, + loading, + operationLoading, }: FileTreeHeaderProps) { const { t } = useTranslation(); return (
+ {/* Title and Toolbar */}

{t('fileTree.files')}

-
+
+ {/* Action buttons */} + {onNewFile && ( + + )} + {onNewFolder && ( + + )} + {onRefresh && ( + + )} + {onCollapseAll && ( + + )} + {/* Divider */} +
+ {/* View mode buttons */} @@ -39,6 +111,7 @@ export default function FileTreeHeader({ className="h-7 w-7 p-0" onClick={() => onViewModeChange('compact')} title={t('fileTree.compactView')} + aria-label={t('fileTree.compactView')} > @@ -48,12 +121,14 @@ export default function FileTreeHeader({ className="h-7 w-7 p-0" onClick={() => onViewModeChange('detailed')} title={t('fileTree.detailedView')} + aria-label={t('fileTree.detailedView')} >
+ {/* Search Bar */}
onSearchQueryChange('')} title={t('fileTree.clearSearch')} + aria-label={t('fileTree.clearSearch')} > @@ -78,4 +154,3 @@ export default function FileTreeHeader({
); } - diff --git a/src/components/file-tree/view/FileTreeList.tsx b/src/components/file-tree/view/FileTreeList.tsx index 3470ab8..65e0737 100644 --- a/src/components/file-tree/view/FileTreeList.tsx +++ b/src/components/file-tree/view/FileTreeList.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types'; import FileTreeNode from './FileTreeNode'; @@ -10,6 +10,21 @@ type FileTreeListProps = { renderFileIcon: (filename: string) => ReactNode; formatFileSize: (bytes?: number) => string; formatRelativeTime: (date?: string) => string; + onRename?: (item: FileTreeNodeType) => void; + onDelete?: (item: FileTreeNodeType) => void; + onNewFile?: (path: string) => void; + onNewFolder?: (path: string) => void; + onCopyPath?: (item: FileTreeNodeType) => void; + onDownload?: (item: FileTreeNodeType) => void; + onRefresh?: () => void; + // Rename state for inline editing + renamingItem?: FileTreeNodeType | null; + renameValue?: string; + setRenameValue?: (value: string) => void; + handleConfirmRename?: () => void; + handleCancelRename?: () => void; + renameInputRef?: RefObject; + operationLoading?: boolean; }; export default function FileTreeList({ @@ -20,6 +35,20 @@ export default function FileTreeList({ renderFileIcon, formatFileSize, formatRelativeTime, + onRename, + onDelete, + onNewFile, + onNewFolder, + onCopyPath, + onDownload, + onRefresh, + renamingItem, + renameValue, + setRenameValue, + handleConfirmRename, + handleCancelRename, + renameInputRef, + operationLoading, }: FileTreeListProps) { return (
@@ -34,9 +63,22 @@ export default function FileTreeList({ renderFileIcon={renderFileIcon} formatFileSize={formatFileSize} formatRelativeTime={formatRelativeTime} + onRename={onRename} + onDelete={onDelete} + onNewFile={onNewFile} + onNewFolder={onNewFolder} + onCopyPath={onCopyPath} + onDownload={onDownload} + onRefresh={onRefresh} + renamingItem={renamingItem} + renameValue={renameValue} + setRenameValue={setRenameValue} + handleConfirmRename={handleConfirmRename} + handleCancelRename={handleCancelRename} + renameInputRef={renameInputRef} + operationLoading={operationLoading} /> ))}
); } - diff --git a/src/components/file-tree/view/FileTreeNode.tsx b/src/components/file-tree/view/FileTreeNode.tsx index 05e43ee..67d04df 100644 --- a/src/components/file-tree/view/FileTreeNode.tsx +++ b/src/components/file-tree/view/FileTreeNode.tsx @@ -1,6 +1,8 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { ChevronRight, Folder, FolderOpen } from 'lucide-react'; import { cn } from '../../../lib/utils'; +import FileContextMenu from '../../FileContextMenu'; +import { Input } from '../../ui/input'; import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types'; type FileTreeNodeProps = { @@ -12,6 +14,21 @@ type FileTreeNodeProps = { renderFileIcon: (filename: string) => ReactNode; formatFileSize: (bytes?: number) => string; formatRelativeTime: (date?: string) => string; + onRename?: (item: FileTreeNodeType) => void; + onDelete?: (item: FileTreeNodeType) => void; + onNewFile?: (path: string) => void; + onNewFolder?: (path: string) => void; + onCopyPath?: (item: FileTreeNodeType) => void; + onDownload?: (item: FileTreeNodeType) => void; + onRefresh?: () => void; + // Rename state for inline editing + renamingItem?: FileTreeNodeType | null; + renameValue?: string; + setRenameValue?: (value: string) => void; + handleConfirmRename?: () => void; + handleCancelRename?: () => void; + renameInputRef?: RefObject; + operationLoading?: boolean; }; type TreeItemIconProps = { @@ -51,10 +68,25 @@ export default function FileTreeNode({ renderFileIcon, formatFileSize, formatRelativeTime, + onRename, + onDelete, + onNewFile, + onNewFolder, + onCopyPath, + onDownload, + onRefresh, + renamingItem, + renameValue, + setRenameValue, + handleConfirmRename, + handleCancelRename, + renameInputRef, + operationLoading, }: FileTreeNodeProps) { const isDirectory = item.type === 'directory'; const isOpen = isDirectory && expandedDirs.has(item.path); const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0); + const isRenaming = renamingItem?.path === item.path; const nameClassName = cn( 'text-[13px] leading-tight truncate', @@ -72,47 +104,100 @@ export default function FileTreeNode({ (isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '', ); - return ( -
+ // Render rename input if this item is being renamed + if (isRenaming && setRenameValue && handleConfirmRename && handleCancelRename) { + return (
onItemClick(item)} + onClick={(e) => e.stopPropagation()} > - {viewMode === 'detailed' ? ( - <> -
- - {item.name} -
-
- {item.type === 'file' ? formatFileSize(item.size) : ''} -
-
{formatRelativeTime(item.modified)}
-
{item.permissionsRwx || ''}
- - ) : viewMode === 'compact' ? ( - <> -
- - {item.name} -
-
- {item.type === 'file' && ( - <> - {formatFileSize(item.size)} - {item.permissionsRwx} - - )} -
- - ) : ( - <> + + setRenameValue(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') handleConfirmRename(); + if (e.key === 'Escape') handleCancelRename(); + }} + onBlur={() => { + setTimeout(() => { + handleConfirmRename(); + }, 100); + }} + className="h-6 text-sm flex-1" + disabled={operationLoading} + /> +
+ ); + } + + const rowContent = ( +
onItemClick(item)} + > + {viewMode === 'detailed' ? ( + <> +
{item.name} - - )} -
+
+
+ {item.type === 'file' ? formatFileSize(item.size) : ''} +
+
{formatRelativeTime(item.modified)}
+
{item.permissionsRwx || ''}
+ + ) : viewMode === 'compact' ? ( + <> +
+ + {item.name} +
+
+ {item.type === 'file' && ( + <> + {formatFileSize(item.size)} + {item.permissionsRwx} + + )} +
+ + ) : ( + <> + + {item.name} + + )} +
+ ); + + // Check if context menu callbacks are provided + const hasContextMenu = onRename || onDelete || onNewFile || onNewFolder || onCopyPath || onDownload || onRefresh; + + return ( +
+ {hasContextMenu ? ( + + {rowContent} + + ) : ( + rowContent + )} {isDirectory && isOpen && hasChildren && (
@@ -132,6 +217,20 @@ export default function FileTreeNode({ renderFileIcon={renderFileIcon} formatFileSize={formatFileSize} formatRelativeTime={formatRelativeTime} + onRename={onRename} + onDelete={onDelete} + onNewFile={onNewFile} + onNewFolder={onNewFolder} + onCopyPath={onCopyPath} + onDownload={onDownload} + onRefresh={onRefresh} + renamingItem={renamingItem} + renameValue={renameValue} + setRenameValue={setRenameValue} + handleConfirmRename={handleConfirmRename} + handleCancelRename={handleCancelRename} + renameInputRef={renameInputRef} + operationLoading={operationLoading} /> ))}
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index e0c3dbf..3a03022 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -107,7 +107,7 @@ function MainContent({ />
-
+
authenticatedFetch(`/api/projects/${projectName}/files`, options), + + // File operations + createFile: (projectName, { path, type, name }) => + authenticatedFetch(`/api/projects/${projectName}/files/create`, { + method: 'POST', + body: JSON.stringify({ path, type, name }), + }), + + renameFile: (projectName, { oldPath, newName }) => + authenticatedFetch(`/api/projects/${projectName}/files/rename`, { + method: 'PUT', + body: JSON.stringify({ oldPath, newName }), + }), + + deleteFile: (projectName, { path, type }) => + authenticatedFetch(`/api/projects/${projectName}/files`, { + method: 'DELETE', + body: JSON.stringify({ path, type }), + }), + + uploadFiles: (projectName, formData) => + authenticatedFetch(`/api/projects/${projectName}/files/upload`, { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }), + transcribe: (formData) => authenticatedFetch('/api/transcribe', { method: 'POST', @@ -187,4 +214,22 @@ export const api = { // Generic GET method for any endpoint get: (endpoint) => authenticatedFetch(`/api${endpoint}`), + + // Generic POST method for any endpoint + post: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, { + method: 'POST', + ...(body instanceof FormData ? { body } : { body: JSON.stringify(body) }), + }), + + // Generic PUT method for any endpoint + put: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, { + method: 'PUT', + body: JSON.stringify(body), + }), + + // Generic DELETE method for any endpoint + delete: (endpoint, options = {}) => authenticatedFetch(`/api${endpoint}`, { + method: 'DELETE', + ...options, + }), }; \ No newline at end of file