refactor: Modularize UI components and utilities
- Extract UI components into separate JS files - Centralize configuration values in config.js - Introduce a dedicated logger module - Improve file tree drag-and-drop and undo functionality - Refactor modal handling to a single manager - Add URL routing support for SPA navigation - Implement view mode for read-only access
This commit is contained in:
@@ -14,32 +14,10 @@ class FileTreeActions {
|
||||
/**
|
||||
* Validate and sanitize filename/folder name
|
||||
* Returns { valid: boolean, sanitized: string, message: string }
|
||||
* Now uses ValidationUtils from utils.js
|
||||
*/
|
||||
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: '' };
|
||||
return ValidationUtils.validateFileName(name, isFolder);
|
||||
}
|
||||
|
||||
async execute(action, targetPath, isDirectory) {
|
||||
@@ -48,7 +26,7 @@ class FileTreeActions {
|
||||
console.error(`Unknown action: ${action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await handler.call(this, targetPath, isDirectory);
|
||||
} catch (error) {
|
||||
@@ -58,140 +36,198 @@ class FileTreeActions {
|
||||
}
|
||||
|
||||
actions = {
|
||||
open: async function(path, isDir) {
|
||||
open: async function (path, isDir) {
|
||||
if (!isDir) {
|
||||
await this.editor.loadFile(path);
|
||||
}
|
||||
},
|
||||
|
||||
'new-file': async function(path, isDir) {
|
||||
'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;
|
||||
}
|
||||
|
||||
const filename = await window.ModalManager.prompt(
|
||||
'Enter filename (lowercase, underscore only):',
|
||||
'new_file.md',
|
||||
'New File'
|
||||
);
|
||||
|
||||
if (!filename) return;
|
||||
|
||||
let finalFilename = filename;
|
||||
const validation = this.validateFileName(filename, false);
|
||||
|
||||
if (!validation.valid) {
|
||||
showNotification(validation.message, 'warning');
|
||||
|
||||
// Ask if user wants to use sanitized version
|
||||
if (validation.sanitized) {
|
||||
const useSanitized = await window.ModalManager.confirm(
|
||||
`${filename} → ${validation.sanitized}`,
|
||||
'Use sanitized name?',
|
||||
false
|
||||
);
|
||||
if (useSanitized) {
|
||||
finalFilename = 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);
|
||||
});
|
||||
}
|
||||
|
||||
const fullPath = `${path}/${finalFilename}`.replace(/\/+/g, '/');
|
||||
await this.webdavClient.put(fullPath, '# New File\n\n');
|
||||
|
||||
// Clear undo history since new file was created
|
||||
if (this.fileTree.lastMoveOperation) {
|
||||
this.fileTree.lastMoveOperation = null;
|
||||
}
|
||||
|
||||
await this.fileTree.load();
|
||||
showNotification(`Created ${finalFilename}`, 'success');
|
||||
await this.editor.loadFile(fullPath);
|
||||
},
|
||||
|
||||
'new-folder': async function(path, isDir) {
|
||||
'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;
|
||||
}
|
||||
|
||||
const foldername = await window.ModalManager.prompt(
|
||||
'Enter folder name (lowercase, underscore only):',
|
||||
'new_folder',
|
||||
'New Folder'
|
||||
);
|
||||
|
||||
if (!foldername) return;
|
||||
|
||||
let finalFoldername = foldername;
|
||||
const validation = this.validateFileName(foldername, true);
|
||||
|
||||
if (!validation.valid) {
|
||||
showNotification(validation.message, 'warning');
|
||||
|
||||
if (validation.sanitized) {
|
||||
const useSanitized = await window.ModalManager.confirm(
|
||||
`${foldername} → ${validation.sanitized}`,
|
||||
'Use sanitized name?',
|
||||
false
|
||||
);
|
||||
if (useSanitized) {
|
||||
finalFoldername = 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');
|
||||
});
|
||||
}
|
||||
|
||||
const fullPath = `${path}/${finalFoldername}`.replace(/\/+/g, '/');
|
||||
await this.webdavClient.mkcol(fullPath);
|
||||
|
||||
// Clear undo history since new folder was created
|
||||
if (this.fileTree.lastMoveOperation) {
|
||||
this.fileTree.lastMoveOperation = null;
|
||||
}
|
||||
|
||||
await this.fileTree.load();
|
||||
showNotification(`Created folder ${finalFoldername}`, 'success');
|
||||
},
|
||||
|
||||
rename: async function(path, isDir) {
|
||||
rename: async function (path, isDir) {
|
||||
const oldName = path.split('/').pop();
|
||||
const newName = await this.showInputDialog('Rename to:', oldName);
|
||||
const newName = await window.ModalManager.prompt(
|
||||
'Rename to:',
|
||||
oldName,
|
||||
'Rename'
|
||||
);
|
||||
|
||||
if (newName && newName !== oldName) {
|
||||
const parentPath = path.substring(0, path.lastIndexOf('/'));
|
||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
||||
await this.webdavClient.move(path, newPath);
|
||||
|
||||
// Clear undo history since manual rename occurred
|
||||
if (this.fileTree.lastMoveOperation) {
|
||||
this.fileTree.lastMoveOperation = null;
|
||||
}
|
||||
|
||||
await this.fileTree.load();
|
||||
showNotification('Renamed', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
copy: async function(path, isDir) {
|
||||
copy: async function (path, isDir) {
|
||||
this.clipboard = { path, operation: 'copy', isDirectory: isDir };
|
||||
showNotification(`Copied: ${path.split('/').pop()}`, 'info');
|
||||
// No notification for copy - it's a quick operation
|
||||
this.updatePasteMenuItem();
|
||||
},
|
||||
|
||||
cut: async function(path, isDir) {
|
||||
cut: async function (path, isDir) {
|
||||
this.clipboard = { path, operation: 'cut', isDirectory: isDir };
|
||||
showNotification(`Cut: ${path.split('/').pop()}`, 'warning');
|
||||
// No notification for cut - it's a quick operation
|
||||
this.updatePasteMenuItem();
|
||||
},
|
||||
|
||||
paste: async function(targetPath, isDir) {
|
||||
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');
|
||||
// No notification for paste - file tree updates show the result
|
||||
} else {
|
||||
await this.webdavClient.move(this.clipboard.path, destPath);
|
||||
this.clipboard = null;
|
||||
this.updatePasteMenuItem();
|
||||
showNotification('Moved', 'success');
|
||||
// No notification for move - file tree updates show the result
|
||||
}
|
||||
|
||||
|
||||
await this.fileTree.load();
|
||||
},
|
||||
|
||||
delete: async function(path, isDir) {
|
||||
delete: async function (path, isDir) {
|
||||
const name = path.split('/').pop();
|
||||
const type = isDir ? 'folder' : 'file';
|
||||
|
||||
if (!await this.showConfirmDialog(`Delete this ${type}?`, `${name}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const confirmed = await window.ModalManager.confirm(
|
||||
`Are you sure you want to delete ${name}?`,
|
||||
`Delete this ${type}?`,
|
||||
true
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
await this.webdavClient.delete(path);
|
||||
|
||||
// Clear undo history since manual delete occurred
|
||||
if (this.fileTree.lastMoveOperation) {
|
||||
this.fileTree.lastMoveOperation = null;
|
||||
}
|
||||
|
||||
await this.fileTree.load();
|
||||
showNotification(`Deleted ${name}`, 'success');
|
||||
},
|
||||
|
||||
download: async function(path, isDir) {
|
||||
showNotification('Downloading...', 'info');
|
||||
// Implementation here
|
||||
download: async function (path, isDir) {
|
||||
Logger.info(`Downloading ${isDir ? 'folder' : 'file'}: ${path}`);
|
||||
|
||||
if (isDir) {
|
||||
await this.fileTree.downloadFolder(path);
|
||||
} else {
|
||||
await this.fileTree.downloadFile(path);
|
||||
}
|
||||
},
|
||||
|
||||
upload: async function(path, isDir) {
|
||||
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) {
|
||||
@@ -202,156 +238,12 @@ class FileTreeActions {
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Old deprecated modal methods removed - all modals now use window.ModalManager
|
||||
|
||||
updatePasteMenuItem() {
|
||||
const pasteItem = document.getElementById('pasteMenuItem');
|
||||
|
||||
Reference in New Issue
Block a user