/** * File Tree Component * Manages the hierarchical file tree display and interactions */ class FileTree { constructor(containerId, webdavClient) { this.container = document.getElementById(containerId); this.webdavClient = webdavClient; this.tree = []; this.selectedPath = null; this.onFileSelect = null; this.onFolderSelect = null; this.setupEventListeners(); } setupEventListeners() { this.container.addEventListener('click', (e) => { const node = e.target.closest('.tree-node'); if (!node) return; const path = node.dataset.path; const isDir = node.dataset.isdir === 'true'; // If it's a directory, and the click was on the title, select the folder if (isDir && e.target.classList.contains('tree-node-title')) { this.selectFolder(path); } else if (!isDir) { // If it's a file, select the file this.selectFile(path); } // Clicks on the toggle are handled by the toggle's specific event listener }); // DRAG AND DROP this.container.addEventListener('dragstart', (e) => { const node = e.target.closest('.tree-node'); if (!node) return; const path = node.dataset.path; const isDir = node.dataset.isdir === 'true'; console.log('[FileTree] Drag start:', path); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', path); e.dataTransfer.setData('application/json', JSON.stringify({ path, isDir, name: node.querySelector('.tree-node-title').textContent })); node.classList.add('dragging'); setTimeout(() => node.classList.remove('dragging'), 0); }); this.container.addEventListener('dragover', (e) => { const node = e.target.closest('.tree-node'); if (!node) return; const isDir = node.dataset.isdir === 'true'; if (!isDir) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; node.classList.add('drag-over'); }); this.container.addEventListener('dragleave', (e) => { const node = e.target.closest('.tree-node'); if (node) { node.classList.remove('drag-over'); } }); this.container.addEventListener('drop', async (e) => { const targetNode = e.target.closest('.tree-node'); if (!targetNode) return; e.preventDefault(); e.stopPropagation(); const targetPath = targetNode.dataset.path; const isDir = targetNode.dataset.isdir === 'true'; if (!isDir) { console.log('[FileTree] Target is not a directory'); return; } try { const data = JSON.parse(e.dataTransfer.getData('application/json')); const sourcePath = data.path; const sourceName = data.name; if (sourcePath === targetPath) { console.log('[FileTree] Source and target are same'); return; } const destPath = `${targetPath}/${sourceName}`.replace(/\/+/g, '/'); console.log('[FileTree] Moving:', sourcePath, '→', destPath); await this.webdavClient.move(sourcePath, destPath); await this.load(); showNotification(`Moved to ${targetNode.querySelector('.tree-node-title').textContent}`, 'success'); } catch (error) { console.error('[FileTree] Drop error:', error); showNotification(`Failed to move: ${error.message}`, 'error'); } finally { targetNode.classList.remove('drag-over'); } }); // Context menu this.container.addEventListener('contextmenu', (e) => { const node = e.target.closest('.tree-node'); e.preventDefault(); if (node) { const path = node.dataset.path; const isDir = node.dataset.isdir === 'true'; window.showContextMenu(e.clientX, e.clientY, { path, isDir }); } }); } async load() { try { const items = await this.webdavClient.propfind('', 'infinity'); this.tree = this.webdavClient.buildTree(items); this.render(); } catch (error) { console.error('Failed to load file tree:', error); showNotification('Failed to load files', 'error'); } } render() { this.container.innerHTML = ''; this.renderNodes(this.tree, this.container, 0); } renderNodes(nodes, parentElement, level) { nodes.forEach(node => { const nodeWrapper = document.createElement('div'); nodeWrapper.className = 'tree-node-wrapper'; // Create node element const nodeElement = this.createNodeElement(node, level); nodeWrapper.appendChild(nodeElement); // Create children container ONLY if has children if (node.children && node.children.length > 0) { const childContainer = document.createElement('div'); childContainer.className = 'tree-children'; childContainer.style.display = 'none'; childContainer.dataset.parent = node.path; childContainer.style.marginLeft = `${(level + 1) * 12}px`; // Recursively render children this.renderNodes(node.children, childContainer, level + 1); nodeWrapper.appendChild(childContainer); // Make toggle functional // The toggle functionality is already handled in renderNodes, no need to duplicate here. // Ensure the toggle's click event stops propagation to prevent the parent node's click from firing. } parentElement.appendChild(nodeWrapper); }); } // toggleFolder is no longer needed as the event listener is added in renderNodes. selectFile(path) { this.selectedPath = path; this.updateSelection(); if (this.onFileSelect) { this.onFileSelect({ path, isDirectory: false }); } } selectFolder(path) { this.selectedPath = path; this.updateSelection(); if (this.onFolderSelect) { this.onFolderSelect({ path, isDirectory: true }); } } updateSelection() { // Remove previous selection this.container.querySelectorAll('.tree-node').forEach(node => { node.classList.remove('selected'); }); // Add selection to current if (this.selectedPath) { const node = this.container.querySelector(`[data-path="${this.selectedPath}"]`); if (node) { node.classList.add('selected'); } } } createNodeElement(node, level) { const nodeElement = document.createElement('div'); nodeElement.className = 'tree-node'; nodeElement.dataset.path = node.path; nodeElement.dataset.isdir = node.isDirectory; nodeElement.style.paddingLeft = `${level * 12}px`; const icon = document.createElement('span'); icon.className = 'tree-node-icon'; if (node.isDirectory) { icon.innerHTML = '▶'; // Collapsed by default icon.classList.add('tree-node-toggle'); } else { icon.innerHTML = '●'; // File icon } const title = document.createElement('span'); title.className = 'tree-node-title'; title.textContent = node.name; nodeElement.appendChild(icon); nodeElement.appendChild(title); return nodeElement; } formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i]; } newFile() { this.selectedPath = null; this.updateSelection(); // Potentially clear editor via callback if (this.onFileSelect) { this.onFileSelect({ path: null, isDirectory: false }); } } async createFile(parentPath, filename) { try { const fullPath = parentPath ? `${parentPath}/${filename}` : filename; await this.webdavClient.put(fullPath, '# New File\n\nStart typing...\n'); await this.load(); this.selectFile(fullPath); // Select the new file showNotification('File created', 'success'); return fullPath; } catch (error) { console.error('Failed to create file:', error); showNotification('Failed to create file', 'error'); throw error; } } async createFolder(parentPath, foldername) { try { const fullPath = parentPath ? `${parentPath}/${foldername}` : foldername; await this.webdavClient.mkcol(fullPath); await this.load(); showNotification('Folder created', 'success'); return fullPath; } catch (error) { console.error('Failed to create folder:', error); showNotification('Failed to create folder', 'error'); throw error; } } async uploadFile(parentPath, file) { try { const fullPath = parentPath ? `${parentPath}/${file.name}` : file.name; const content = await file.arrayBuffer(); await this.webdavClient.putBinary(fullPath, content); await this.load(); showNotification(`Uploaded ${file.name}`, 'success'); return fullPath; } catch (error) { console.error('Failed to upload file:', error); showNotification('Failed to upload file', 'error'); throw error; } } async downloadFile(path) { try { const content = await this.webdavClient.get(path); const filename = path.split('/').pop(); this.triggerDownload(content, filename); showNotification('Downloaded', 'success'); } catch (error) { console.error('Failed to download file:', error); showNotification('Failed to download file', 'error'); } } async downloadFolder(path) { try { showNotification('Creating zip...', 'info'); // Get all files in folder const items = await this.webdavClient.propfind(path, 'infinity'); const files = items.filter(item => !item.isDirectory); // Use JSZip to create zip file const JSZip = window.JSZip; if (!JSZip) { throw new Error('JSZip not loaded'); } const zip = new JSZip(); const folder = zip.folder(path.split('/').pop() || 'download'); // Add all files to zip for (const file of files) { const content = await this.webdavClient.get(file.path); const relativePath = file.path.replace(path + '/', ''); folder.file(relativePath, content); } // Generate zip const zipBlob = await zip.generateAsync({ type: 'blob' }); const zipFilename = `${path.split('/').pop() || 'download'}.zip`; this.triggerDownload(zipBlob, zipFilename); showNotification('Downloaded', 'success'); } catch (error) { console.error('Failed to download folder:', error); showNotification('Failed to download folder', 'error'); } } triggerDownload(content, filename) { const blob = content instanceof Blob ? content : new Blob([content]); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } }