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:
+493
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user