/** * File Tree Actions Manager * Centralized handling of all tree operations */ class FileTreeActions { constructor(webdavClient, fileTree, editor) { this.webdavClient = webdavClient; this.fileTree = fileTree; this.editor = editor; this.clipboard = null; } /** * Validate and sanitize filename/folder name * Returns { valid: boolean, sanitized: string, message: string } */ validateFileName(name, isFolder = false) { const type = isFolder ? 'folder' : 'file'; if (!name || name.trim().length === 0) { return { valid: false, message: `${type} name cannot be empty` }; } // Check for invalid characters const validPattern = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/; if (!validPattern.test(name)) { const sanitized = name .toLowerCase() .replace(/[^a-z0-9_.]/g, '_') .replace(/_+/g, '_') .replace(/^_+|_+$/g, ''); return { valid: false, sanitized, message: `Invalid characters in ${type} name. Only lowercase letters, numbers, and underscores allowed.\n\nSuggestion: "${sanitized}"` }; } return { valid: true, sanitized: name, message: '' }; } async execute(action, targetPath, isDirectory) { const handler = this.actions[action]; if (!handler) { console.error(`Unknown action: ${action}`); return; } try { await handler.call(this, targetPath, isDirectory); } catch (error) { console.error(`Action failed: ${action}`, error); showNotification(`Failed to ${action}`, 'error'); } } actions = { open: async function(path, isDir) { if (!isDir) { await this.editor.loadFile(path); } }, 'new-file': async function(path, isDir) { if (!isDir) return; await this.showInputDialog('Enter filename (lowercase, underscore only):', 'new_file.md', async (filename) => { if (!filename) return; const validation = this.validateFileName(filename, false); if (!validation.valid) { showNotification(validation.message, 'warning'); // Ask if user wants to use sanitized version if (validation.sanitized) { if (await this.showConfirmDialog('Use sanitized name?', `${filename} → ${validation.sanitized}`)) { filename = validation.sanitized; } else { return; } } else { return; } } const fullPath = `${path}/${filename}`.replace(/\/+/g, '/'); await this.webdavClient.put(fullPath, '# New File\n\n'); await this.fileTree.load(); showNotification(`Created ${filename}`, 'success'); await this.editor.loadFile(fullPath); }); }, 'new-folder': async function(path, isDir) { if (!isDir) return; await this.showInputDialog('Enter folder name (lowercase, underscore only):', 'new_folder', async (foldername) => { if (!foldername) return; const validation = this.validateFileName(foldername, true); if (!validation.valid) { showNotification(validation.message, 'warning'); if (validation.sanitized) { if (await this.showConfirmDialog('Use sanitized name?', `${foldername} → ${validation.sanitized}`)) { foldername = validation.sanitized; } else { return; } } else { return; } } const fullPath = `${path}/${foldername}`.replace(/\/+/g, '/'); await this.webdavClient.mkcol(fullPath); await this.fileTree.load(); showNotification(`Created folder ${foldername}`, 'success'); }); }, rename: async function(path, isDir) { const oldName = path.split('/').pop(); const newName = await this.showInputDialog('Rename to:', oldName); if (newName && newName !== oldName) { const parentPath = path.substring(0, path.lastIndexOf('/')); const newPath = parentPath ? `${parentPath}/${newName}` : newName; await this.webdavClient.move(path, newPath); await this.fileTree.load(); showNotification('Renamed', 'success'); } }, copy: async function(path, isDir) { this.clipboard = { path, operation: 'copy', isDirectory: isDir }; showNotification(`Copied: ${path.split('/').pop()}`, 'info'); this.updatePasteMenuItem(); }, cut: async function(path, isDir) { this.clipboard = { path, operation: 'cut', isDirectory: isDir }; showNotification(`Cut: ${path.split('/').pop()}`, 'warning'); this.updatePasteMenuItem(); }, paste: async function(targetPath, isDir) { if (!this.clipboard || !isDir) return; const itemName = this.clipboard.path.split('/').pop(); const destPath = `${targetPath}/${itemName}`.replace(/\/+/g, '/'); if (this.clipboard.operation === 'copy') { await this.webdavClient.copy(this.clipboard.path, destPath); showNotification('Copied', 'success'); } else { await this.webdavClient.move(this.clipboard.path, destPath); this.clipboard = null; this.updatePasteMenuItem(); showNotification('Moved', 'success'); } await this.fileTree.load(); }, delete: async function(path, isDir) { const name = path.split('/').pop(); const type = isDir ? 'folder' : 'file'; if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) { return; } await this.webdavClient.delete(path); await this.fileTree.load(); showNotification(`Deleted ${name}`, 'success'); }, download: async function(path, isDir) { showNotification('Downloading...', 'info'); // Implementation here }, upload: async function(path, isDir) { if (!isDir) return; const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.onchange = async (e) => { const files = Array.from(e.target.files); for (const file of files) { const fullPath = `${path}/${file.name}`.replace(/\/+/g, '/'); const content = await file.arrayBuffer(); await this.webdavClient.putBinary(fullPath, content); showNotification(`Uploaded ${file.name}`, 'success'); } await this.fileTree.load(); }; input.click(); } }; // Modern dialog implementations async showInputDialog(title, placeholder = '', callback) { return new Promise((resolve) => { const dialog = this.createInputDialog(title, placeholder); const input = dialog.querySelector('input'); const confirmBtn = dialog.querySelector('.btn-primary'); const cancelBtn = dialog.querySelector('.btn-secondary'); const cleanup = (value) => { const modalInstance = bootstrap.Modal.getInstance(dialog); if (modalInstance) { modalInstance.hide(); } dialog.remove(); const backdrop = document.querySelector('.modal-backdrop'); if (backdrop) backdrop.remove(); document.body.classList.remove('modal-open'); resolve(value); if (callback) callback(value); }; confirmBtn.onclick = () => { cleanup(input.value.trim()); }; cancelBtn.onclick = () => { cleanup(null); }; dialog.addEventListener('hidden.bs.modal', () => { cleanup(null); }); input.onkeypress = (e) => { if (e.key === 'Enter') confirmBtn.click(); }; document.body.appendChild(dialog); const modal = new bootstrap.Modal(dialog); modal.show(); input.focus(); input.select(); }); } async showConfirmDialog(title, message = '', callback) { return new Promise((resolve) => { const dialog = this.createConfirmDialog(title, message); const confirmBtn = dialog.querySelector('.btn-danger'); const cancelBtn = dialog.querySelector('.btn-secondary'); const cleanup = (value) => { const modalInstance = bootstrap.Modal.getInstance(dialog); if (modalInstance) { modalInstance.hide(); } dialog.remove(); const backdrop = document.querySelector('.modal-backdrop'); if (backdrop) backdrop.remove(); document.body.classList.remove('modal-open'); resolve(value); if (callback) callback(value); }; confirmBtn.onclick = () => { cleanup(true); }; cancelBtn.onclick = () => { cleanup(false); }; dialog.addEventListener('hidden.bs.modal', () => { cleanup(false); }); document.body.appendChild(dialog); const modal = new bootstrap.Modal(dialog); modal.show(); confirmBtn.focus(); }); } createInputDialog(title, placeholder) { const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop fade show'; const dialog = document.createElement('div'); dialog.className = 'modal fade show d-block'; dialog.setAttribute('tabindex', '-1'); dialog.style.display = 'block'; dialog.innerHTML = ` `; document.body.appendChild(backdrop); return dialog; } createConfirmDialog(title, message) { const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop fade show'; const dialog = document.createElement('div'); dialog.className = 'modal fade show d-block'; dialog.setAttribute('tabindex', '-1'); dialog.style.display = 'block'; dialog.innerHTML = ` `; document.body.appendChild(backdrop); return dialog; } updatePasteMenuItem() { const pasteItem = document.getElementById('pasteMenuItem'); if (pasteItem) { pasteItem.style.display = this.clipboard ? 'flex' : 'none'; } } }