356 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			356 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * 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);
 | |
|     }
 | |
| }
 | |
| 
 |