feat: drag & drop de fichiers dans l'arborescence (portion 5)
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 13s

- Backend: nouvel endpoint POST /api/move/{vault_name} pour déplacer
  fichiers et dossiers vers un autre répertoire parent
- Frontend: module dragdrop.js avec délégation d'événements sur le
  vault-tree (dragstart, dragover, dragleave, drop)
- CSS: styles pour drag-source, drag-valid-target, drag-over
- Protections: pas de drop sur soi-même, pas dans un enfant,
  pas de cross-vault
- Mise à jour de l'index et broadcast SSE après move
- ROADMAP: portion 5 marquée comme complétée
This commit is contained in:
2026-06-03 13:56:27 -04:00
parent 96c1e9e6ec
commit cfb3825dbe
5 changed files with 498 additions and 2 deletions
+127
View File
@@ -319,9 +319,24 @@ class FileRenameResponse(BaseModel):
new_path: str
class FileMoveRequest(BaseModel):
"""Request to move a file or directory to a different parent directory."""
source_path: str = Field(description="Current relative path of the file/directory")
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
class FileMoveResponse(BaseModel):
"""Response after moving a file or directory."""
success: bool = Field(description="Whether move succeeded")
old_path: str = Field(description="Original path")
new_path: str = Field(description="New path after move")
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
# ---------------------------------------------------------------------------
# SSE Manager — Server-Sent Events for real-time notifications
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
class SSEManager:
"""Manages SSE client connections and broadcasts events."""
@@ -1748,6 +1763,118 @@ async def api_file_rename(
raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
async def api_file_move(
vault_name: str,
body: FileMoveRequest,
current_user=Depends(require_auth),
):
"""Move a file or directory to a different parent directory within the same vault.
Supports both files and directories. The item keeps its original name;
only the parent directory changes.
Args:
vault_name: Name of the vault.
body: Request body with source_path and destination_dir.
Returns:
FileMoveResponse 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 source path
source = _resolve_safe_path(vault_root, body.source_path)
if not source.exists():
raise HTTPException(status_code=404, detail=f"Source not found: {body.source_path}")
is_directory = source.is_dir()
# Determine the item name (last segment)
item_name = source.name
# Construct destination path: destination_dir / item_name
dest_dir_clean = body.destination_dir.strip("/")
if dest_dir_clean:
dest_parent = _resolve_safe_path(vault_root, dest_dir_clean)
if not dest_parent.exists() or not dest_parent.is_dir():
raise HTTPException(status_code=404, detail=f"Destination directory not found: {body.destination_dir}")
else:
dest_parent = vault_root
destination = dest_parent / item_name
# Validate destination is within vault
destination = _resolve_safe_path(vault_root, str(destination.relative_to(vault_root)))
# Don't move to the same location
if source.resolve() == destination.resolve():
return {
"success": True,
"old_path": body.source_path,
"new_path": body.source_path,
"item_type": "directory" if is_directory else "file",
}
# Check if destination already exists
if destination.exists():
raise HTTPException(status_code=409, detail=f"A file or directory already exists at the destination: {destination.name}")
# For files, validate extension
if not is_directory:
ext = destination.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS and destination.name.lower() not in ("dockerfile", "makefile"):
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
try:
# Move the file/directory
source.rename(destination)
old_path_str = str(source.relative_to(vault_root)).replace("\\", "/")
# After rename, source is now at destination — reconstruct the new relative path
new_path_str = body.destination_dir.strip("/")
new_path_str = (new_path_str + "/" if new_path_str else "") + item_name
item_type = "directory" if is_directory else "file"
logger.info(f"Item moved: {vault_name}/{old_path_str} -> {new_path_str}")
# Update index
if is_directory:
from backend.indexer import reload_single_vault
await reload_single_vault(vault_name)
else:
await handle_file_move(vault_name, old_path_str, new_path_str)
# Broadcast SSE event
await sse_manager.broadcast("item_moved", {
"vault": vault_name,
"old_path": old_path_str,
"new_path": new_path_str,
"item_type": item_type,
})
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: cannot move item")
except Exception as e:
logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}")
raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}")
@app.get("/api/file/{vault_name}/backlinks")
async def api_file_backlinks(
vault_name: str,