Files
markdown_editor/static/js/file-tree.js
2025-10-26 10:52:27 +04:00

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);
}
}