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

362 lines
13 KiB
JavaScript

/**
* 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 = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control" value="${placeholder}" autofocus>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-danger">
<h5 class="modal-title text-danger">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>${message}</p>
<p class="text-danger small">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
`;
document.body.appendChild(backdrop);
return dialog;
}
updatePasteMenuItem() {
const pasteItem = document.getElementById('pasteMenuItem');
if (pasteItem) {
pasteItem.style.display = this.clipboard ? 'flex' : 'none';
}
}
}