feat: add file and directory management endpoints with context menu support

- Add POST/PATCH/DELETE endpoints for directory operations (create, rename, delete)
- Add POST/PATCH endpoints for file operations (create, rename)
- Implement writable vault check to prevent modifications on read-only vaults
- Update file delete endpoint to broadcast SSE events and update index
- Add Pydantic models for all new request/response schemas
- Integrate context menu support in frontend for files and directories
- Broadcast real
This commit is contained in:
2026-03-30 15:26:44 -04:00
parent d26a40a99d
commit d3b9298dfa
3 changed files with 1182 additions and 0 deletions
+493
View File
@@ -2272,6 +2272,12 @@
safeCreateIcons();
}
});
dirItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
});
} else {
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
@@ -2281,6 +2287,13 @@
openFile(vaultName, item.path);
closeMobileSidebar();
});
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
});
fragment.appendChild(fileItem);
}
});
@@ -5486,6 +5499,485 @@
panel.innerHTML = html;
}
// ---------------------------------------------------------------------------
// Context Menu Manager
// ---------------------------------------------------------------------------
const ContextMenuManager = {
_menu: null,
_targetElement: null,
_targetVault: null,
_targetPath: null,
_targetType: null,
init() {
this._menu = document.createElement('div');
this._menu.className = 'context-menu';
this._menu.id = 'context-menu';
document.body.appendChild(this._menu);
document.addEventListener('click', () => this.hide());
document.addEventListener('contextmenu', (e) => {
if (!e.target.closest('.tree-item')) {
this.hide();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.hide();
});
document.addEventListener('scroll', () => this.hide(), true);
},
show(x, y, vault, path, type, isReadonly) {
this._targetVault = vault;
this._targetPath = path;
this._targetType = type;
this._menu.innerHTML = '';
if (type === 'directory') {
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
this._addSeparator();
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
} else if (type === 'file') {
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
}
this._menu.classList.add('active');
const rect = this._menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > viewportWidth) {
finalX = viewportWidth - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
finalY = viewportHeight - rect.height - 10;
}
this._menu.style.left = `${finalX}px`;
this._menu.style.top = `${finalY}px`;
safeCreateIcons();
},
hide() {
if (this._menu) {
this._menu.classList.remove('active');
}
},
_addItem(icon, label, callback, disabled) {
const item = document.createElement('div');
item.className = 'context-menu-item' + (disabled ? ' disabled' : '');
item.innerHTML = `
<i data-lucide="${icon}" class="icon"></i>
<span>${label}</span>
`;
if (!disabled) {
item.addEventListener('click', (e) => {
e.stopPropagation();
this.hide();
callback();
});
} else {
item.title = 'Vault en lecture seule';
}
this._menu.appendChild(item);
},
_addSeparator() {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
this._menu.appendChild(sep);
},
_createDirectory() {
FileOperations.showCreateDirectoryModal(this._targetVault, this._targetPath);
},
_createFile() {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath);
},
_renameItem() {
FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType);
},
_deleteDirectory() {
FileOperations.confirmDeleteDirectory(this._targetVault, this._targetPath);
},
_deleteFile() {
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
}
};
// ---------------------------------------------------------------------------
// File Operations Manager
// ---------------------------------------------------------------------------
const FileOperations = {
showCreateDirectoryModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Créer un dossier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
<label class="modal-label">Nom du dossier</label>
<input type="text" class="modal-input" id="dir-name-input" placeholder="nouveau-dossier" />
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
<div class="modal-error" id="dir-error" style="display:none;"></div>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="dir-cancel-btn">Annuler</button>
<button class="modal-btn primary" id="dir-create-btn">Créer</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
const input = modal.querySelector('#dir-name-input');
const errorDiv = modal.querySelector('#dir-error');
const createBtn = modal.querySelector('#dir-create-btn');
const cancelBtn = modal.querySelector('#dir-cancel-btn');
input.focus();
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
return null;
};
input.addEventListener('input', () => {
const error = validateName(input.value);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
input.classList.add('error');
} else {
errorDiv.style.display = 'none';
input.classList.remove('error');
}
});
const create = async () => {
const name = input.value.trim();
const error = validateName(name);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
return;
}
const path = parentPath ? `${parentPath}/${name}` : name;
createBtn.disabled = true;
createBtn.textContent = 'Création...';
try {
await api(`/api/directory/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
showToast(`Dossier "${name}" créé`, 'success');
this._closeModal(overlay);
await refreshVaultTree();
} catch (err) {
showToast(err.message || 'Erreur lors de la création', 'error');
createBtn.disabled = false;
createBtn.textContent = 'Créer';
}
};
createBtn.addEventListener('click', create);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') create();
if (e.key === 'Escape') this._closeModal(overlay);
});
},
showCreateFileModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Créer un fichier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
<label class="modal-label">Nom du fichier</label>
<input type="text" class="modal-input" id="file-name-input" placeholder="note.md" />
<div class="modal-hint">Dans: ${parentPath || '/'}</div>
<div class="modal-error" id="file-error" style="display:none;"></div>
</div>
<div class="modal-form-group">
<label class="modal-label">Type de fichier</label>
<select class="modal-select" id="file-ext-select">
<option value=".md">Markdown (.md)</option>
<option value=".txt">Texte (.txt)</option>
<option value=".py">Python (.py)</option>
<option value=".js">JavaScript (.js)</option>
<option value=".json">JSON (.json)</option>
<option value=".yaml">YAML (.yaml)</option>
<option value=".sh">Shell (.sh)</option>
<option value=".ps1">PowerShell (.ps1)</option>
</select>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="file-cancel-btn">Annuler</button>
<button class="modal-btn primary" id="file-create-btn">Créer</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
const input = modal.querySelector('#file-name-input');
const extSelect = modal.querySelector('#file-ext-select');
const errorDiv = modal.querySelector('#file-error');
const createBtn = modal.querySelector('#file-create-btn');
const cancelBtn = modal.querySelector('#file-cancel-btn');
input.focus();
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
return null;
};
input.addEventListener('input', () => {
const error = validateName(input.value);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
input.classList.add('error');
} else {
errorDiv.style.display = 'none';
input.classList.remove('error');
}
});
const create = async () => {
let name = input.value.trim();
const error = validateName(name);
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
return;
}
const ext = extSelect.value;
if (!name.endsWith(ext)) {
name += ext;
}
const path = parentPath ? `${parentPath}/${name}` : name;
createBtn.disabled = true;
createBtn.textContent = 'Création...';
try {
await api(`/api/file/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content: '' }),
});
showToast(`Fichier "${name}" créé`, 'success');
this._closeModal(overlay);
await refreshVaultTree();
openFile(vault, path);
} catch (err) {
showToast(err.message || 'Erreur lors de la création', 'error');
createBtn.disabled = false;
createBtn.textContent = 'Créer';
}
};
createBtn.addEventListener('click', create);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') create();
if (e.key === 'Escape') this._closeModal(overlay);
});
},
async startInlineRename(vault, path, type) {
showToast('Fonctionnalité de renommage inline en cours de développement', 'info');
},
confirmDeleteDirectory(vault, path) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Supprimer le dossier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-warning">
<i data-lucide="alert-triangle" class="icon"></i>
<div>
<strong>Attention !</strong> Cette action est irréversible.
<br>Tous les fichiers et sous-dossiers seront supprimés définitivement.
</div>
</div>
<div class="modal-form-group">
<label class="modal-label">Dossier à supprimer:</label>
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
safeCreateIcons();
const confirmBtn = modal.querySelector('#del-confirm-btn');
const cancelBtn = modal.querySelector('#del-cancel-btn');
const deleteDir = async () => {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Suppression...';
try {
const result = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
showToast(`Dossier supprimé (${result.deleted_count} fichiers)`, 'success');
this._closeModal(overlay);
await refreshVaultTree();
if (currentVault === vault && currentPath && currentPath.startsWith(path)) {
showWelcome();
}
} catch (err) {
showToast(err.message || 'Erreur lors de la suppression', 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Supprimer définitivement';
}
};
confirmBtn.addEventListener('click', deleteDir);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
},
confirmDeleteFile(vault, path) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">Supprimer le fichier</h3>
</div>
<div class="obsigate-modal-body">
<div class="modal-warning">
<i data-lucide="alert-triangle" class="icon"></i>
<div>
<strong>Attention !</strong> Cette action est irréversible.
</div>
</div>
<div class="modal-form-group">
<label class="modal-label">Fichier à supprimer:</label>
<div style="font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">${path}</div>
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" id="del-cancel-btn">Annuler</button>
<button class="modal-btn danger" id="del-confirm-btn">Supprimer définitivement</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add('active'), 10);
safeCreateIcons();
const confirmBtn = modal.querySelector('#del-confirm-btn');
const cancelBtn = modal.querySelector('#del-cancel-btn');
const deleteFile = async () => {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Suppression...';
try {
await api(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
showToast('Fichier supprimé', 'success');
this._closeModal(overlay);
await refreshVaultTree();
if (currentVault === vault && currentPath === path) {
showWelcome();
}
} catch (err) {
showToast(err.message || 'Erreur lors de la suppression', 'error');
confirmBtn.disabled = false;
confirmBtn.textContent = 'Supprimer définitivement';
}
};
confirmBtn.addEventListener('click', deleteFile);
cancelBtn.addEventListener('click', () => this._closeModal(overlay));
},
_createModalOverlay() {
const overlay = document.createElement('div');
overlay.className = 'obsigate-modal-overlay';
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this._closeModal(overlay);
}
});
return overlay;
},
_closeModal(overlay) {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
}
};
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
@@ -5933,6 +6425,7 @@
initRecentTab();
RightSidebarManager.init();
FindInPageManager.init();
ContextMenuManager.init();
// Check auth status first
const authOk = await AuthManager.initAuth();
+271
View File
@@ -5019,3 +5019,274 @@ body.popup-mode .content-area {
transform: translateX(100%);
}
}
/* ===== CONTEXT MENU ===== */
.context-menu {
position: fixed;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
min-width: 200px;
z-index: 10000;
padding: 4px;
display: none;
}
.context-menu.active {
display: block;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-family: "JetBrains Mono", monospace;
font-size: 0.82rem;
color: var(--text-primary);
transition: all 150ms ease;
}
.context-menu-item:hover:not(.disabled) {
background: var(--bg-hover);
color: var(--accent);
}
.context-menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.context-menu-item .icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.context-menu-separator {
height: 1px;
background: var(--border);
margin: 4px 0;
}
/* ===== INLINE RENAME ===== */
.sidebar-item-input {
width: 100%;
padding: 4px 8px;
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--text-primary);
font-family: "JetBrains Mono", monospace;
font-size: 0.82rem;
outline: none;
}
/* ===== VAULT READONLY BADGE ===== */
.vault-readonly-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: var(--danger-bg);
color: var(--danger);
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
margin-left: 6px;
}
/* ===== OBSIGATE MODALS ===== */
.obsigate-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--overlay-bg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
}
.obsigate-modal-overlay.active {
opacity: 1;
pointer-events: all;
}
.obsigate-modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
min-width: 400px;
max-width: 600px;
transform: scale(0.9);
transition: transform 200ms ease;
}
.obsigate-modal-overlay.active .obsigate-modal {
transform: scale(1);
}
.obsigate-modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.obsigate-modal-title {
font-family: "JetBrains Mono", monospace;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
}
.obsigate-modal-body {
padding: 20px;
}
.obsigate-modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-input {
width: 100%;
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: "JetBrains Mono", monospace;
font-size: 0.9rem;
outline: none;
transition: border-color 200ms ease;
}
.modal-input:focus {
border-color: var(--accent);
}
.modal-input.error {
border-color: var(--danger);
}
.modal-label {
display: block;
font-family: "JetBrains Mono", monospace;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
}
.modal-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 6px;
}
.modal-error {
font-size: 0.8rem;
color: var(--danger);
margin-top: 6px;
}
.modal-select {
width: 100%;
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: "JetBrains Mono", monospace;
font-size: 0.9rem;
outline: none;
cursor: pointer;
transition: border-color 200ms ease;
}
.modal-select:focus {
border-color: var(--accent);
}
.modal-btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-family: "JetBrains Mono", monospace;
font-size: 0.85rem;
cursor: pointer;
transition: all 150ms ease;
}
.modal-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.modal-btn.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.modal-btn.primary:hover {
opacity: 0.9;
}
.modal-btn.danger {
background: var(--danger);
color: white;
border-color: var(--danger);
}
.modal-btn.danger:hover {
opacity: 0.9;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal-form-group {
margin-bottom: 16px;
}
.modal-form-group:last-child {
margin-bottom: 0;
}
.modal-warning {
padding: 12px;
background: var(--danger-bg);
border: 1px solid var(--danger);
border-radius: 6px;
color: var(--danger);
font-size: 0.85rem;
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.modal-warning .icon {
flex-shrink: 0;
margin-top: 2px;
}