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:
Bruno Charest 2026-03-30 15:26:44 -04:00
parent d26a40a99d
commit d3b9298dfa
3 changed files with 1182 additions and 0 deletions

View File

@ -6,6 +6,7 @@ import html as html_mod
import logging import logging
import mimetypes import mimetypes
import secrets import secrets
import shutil
import string import string
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@ -234,6 +235,61 @@ class HealthResponse(BaseModel):
total_files: int total_files: int
class DirectoryCreateRequest(BaseModel):
"""Request to create a new directory."""
path: str = Field(description="Relative path of the new directory")
class DirectoryCreateResponse(BaseModel):
"""Response after creating a directory."""
success: bool
path: str
class DirectoryRenameRequest(BaseModel):
"""Request to rename a directory."""
path: str = Field(description="Current path of the directory")
new_name: str = Field(description="New name for the directory")
class DirectoryRenameResponse(BaseModel):
"""Response after renaming a directory."""
success: bool
old_path: str
new_path: str
class DirectoryDeleteResponse(BaseModel):
"""Response after deleting a directory."""
success: bool
deleted_count: int
class FileCreateRequest(BaseModel):
"""Request to create a new file."""
path: str = Field(description="Relative path of the new file")
content: str = Field(default="", description="Initial content")
class FileCreateResponse(BaseModel):
"""Response after creating a file."""
success: bool
path: str
class FileRenameRequest(BaseModel):
"""Request to rename a file."""
path: str = Field(description="Current path of the file")
new_name: str = Field(description="New name for the file")
class FileRenameResponse(BaseModel):
"""Response after renaming a file."""
success: bool
old_path: str
new_path: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SSE Manager — Server-Sent Events for real-time notifications # SSE Manager — Server-Sent Events for real-time notifications
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -523,6 +579,18 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
return resolved return resolved
def _check_vault_writable(vault_root: Path) -> bool:
"""Check if a vault is writable (not mounted read-only).
Args:
vault_root: The vault's root directory (absolute).
Returns:
True if the vault is writable, False otherwise.
"""
return os.access(vault_root, os.W_OK)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Markdown rendering helpers (singleton renderer) # Markdown rendering helpers (singleton renderer)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1028,9 +1096,23 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
if not file_path.exists() or not file_path.is_file(): if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}") raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
try: try:
file_path.unlink() file_path.unlink()
logger.info(f"File deleted: {vault_name}/{path}") logger.info(f"File deleted: {vault_name}/{path}")
# Update index
await remove_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_deleted", {
"vault": vault_name,
"path": path,
})
return {"status": "ok", "vault": vault_name, "path": path} return {"status": "ok", "vault": vault_name, "path": path}
except PermissionError: except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only") raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
@ -1039,6 +1121,342 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}") raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
# ---------------------------------------------------------------------------
# Directory management endpoints
# ---------------------------------------------------------------------------
@app.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse)
async def api_directory_create(
vault_name: str,
body: DirectoryCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with directory path.
Returns:
DirectoryCreateResponse confirming creation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
# Resolve and validate path
dir_path = _resolve_safe_path(vault_root, body.path)
# Check if directory already exists
if dir_path.exists():
raise HTTPException(status_code=409, detail=f"Directory already exists: {body.path}")
try:
# Create directory with parents
dir_path.mkdir(parents=True, exist_ok=False)
logger.info(f"Directory created: {vault_name}/{body.path}")
# Broadcast SSE event
await sse_manager.broadcast("directory_created", {
"vault": vault_name,
"path": body.path,
})
return {"success": True, "path": body.path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot create directory")
except Exception as e:
logger.error(f"Error creating directory {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error creating directory: {str(e)}")
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
async def api_directory_rename(
vault_name: str,
body: DirectoryRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a directory in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
DirectoryRenameResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
# Resolve old path
old_path = _resolve_safe_path(vault_root, body.path)
if not old_path.exists() or not old_path.is_dir():
raise HTTPException(status_code=404, detail=f"Directory not found: {body.path}")
# Construct new path (same parent, new name)
new_path = old_path.parent / body.new_name
# Validate new path is still within vault
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
# Check if destination already exists
if new_path.exists():
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
try:
# Rename directory
old_path.rename(new_path)
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
logger.info(f"Directory renamed: {vault_name}/{old_path_str} -> {new_path_str}")
# Update index for all files in the directory
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory")
except Exception as e:
logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error renaming directory: {str(e)}")
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
async def api_directory_delete(
vault_name: str,
path: str = Query(..., description="Relative path to directory"),
current_user=Depends(require_auth),
):
"""Delete a directory and all its contents from a vault.
Args:
vault_name: Name of the vault.
path: Relative directory path within the vault.
Returns:
DirectoryDeleteResponse with count of deleted files.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
# Resolve and validate path
dir_path = _resolve_safe_path(vault_root, path)
if not dir_path.exists() or not dir_path.is_dir():
raise HTTPException(status_code=404, detail=f"Directory not found: {path}")
try:
# Count files before deletion
file_count = sum(1 for _ in dir_path.rglob('*') if _.is_file())
# Delete directory recursively
shutil.rmtree(dir_path)
logger.info(f"Directory deleted: {vault_name}/{path} ({file_count} files)")
# Update index
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
# Broadcast SSE event
await sse_manager.broadcast("directory_deleted", {
"vault": vault_name,
"path": path,
"deleted_count": file_count,
})
return {"success": True, "deleted_count": file_count}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory")
except Exception as e:
logger.error(f"Error deleting directory {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting directory: {str(e)}")
# ---------------------------------------------------------------------------
# File creation and rename endpoints
# ---------------------------------------------------------------------------
@app.post("/api/file/{vault_name}", response_model=FileCreateResponse)
async def api_file_create(
vault_name: str,
body: FileCreateRequest,
current_user=Depends(require_auth),
):
"""Create a new file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with file path and initial content.
Returns:
FileCreateResponse confirming creation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
# Resolve and validate path
file_path = _resolve_safe_path(vault_root, body.path)
# Validate file extension
ext = file_path.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"):
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
# Check if file already exists
if file_path.exists():
raise HTTPException(status_code=409, detail=f"File already exists: {body.path}")
try:
# Create parent directories if needed
file_path.parent.mkdir(parents=True, exist_ok=True)
# Create file with initial content
file_path.write_text(body.content, encoding="utf-8")
logger.info(f"File created: {vault_name}/{body.path}")
# Update index
await update_single_file(vault_name, body.path)
# Broadcast SSE event
await sse_manager.broadcast("file_created", {
"vault": vault_name,
"path": body.path,
})
return {"success": True, "path": body.path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot create file")
except Exception as e:
logger.error(f"Error creating file {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error creating file: {str(e)}")
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
async def api_file_rename(
vault_name: str,
body: FileRenameRequest,
current_user=Depends(require_auth),
):
"""Rename a file in a vault.
Args:
vault_name: Name of the vault.
body: Request body with current path and new name.
Returns:
FileRenameResponse with old and new paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Check if vault is writable
if not _check_vault_writable(vault_root):
raise HTTPException(status_code=403, detail="Vault is read-only")
# Resolve old path
old_path = _resolve_safe_path(vault_root, body.path)
if not old_path.exists() or not old_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {body.path}")
# Construct new path (same parent, new name)
new_path = old_path.parent / body.new_name
# Validate new path is still within vault
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
# Validate file extension
ext = new_path.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and new_path.name.lower() not in ("dockerfile", "makefile"):
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
# Check if destination already exists
if new_path.exists():
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
try:
# Rename file
old_path.rename(new_path)
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
logger.info(f"File renamed: {vault_name}/{old_path_str} -> {new_path_str}")
# Update index
await handle_file_move(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("file_renamed", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot rename file")
except Exception as e:
logger.error(f"Error renaming file {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
@app.get("/api/file/{vault_name}", response_model=FileContentResponse) @app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file. """Return rendered HTML and metadata for a file.

View File

@ -2272,6 +2272,12 @@
safeCreateIcons(); safeCreateIcons();
} }
}); });
dirItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
});
} else { } else {
const fileIconName = getFileIcon(item.name); const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
@ -2281,6 +2287,13 @@
openFile(vaultName, item.path); openFile(vaultName, item.path);
closeMobileSidebar(); closeMobileSidebar();
}); });
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
});
fragment.appendChild(fileItem); fragment.appendChild(fileItem);
} }
}); });
@ -5486,6 +5499,485 @@
panel.innerHTML = html; 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 // Find in Page Manager
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -5933,6 +6425,7 @@
initRecentTab(); initRecentTab();
RightSidebarManager.init(); RightSidebarManager.init();
FindInPageManager.init(); FindInPageManager.init();
ContextMenuManager.init();
// Check auth status first // Check auth status first
const authOk = await AuthManager.initAuth(); const authOk = await AuthManager.initAuth();

View File

@ -5019,3 +5019,274 @@ body.popup-mode .content-area {
transform: translateX(100%); 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;
}