From cfb3825dbea49f156fea067d82c75267edbd775f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 3 Jun 2026 13:56:27 -0400 Subject: [PATCH] feat: drag & drop de fichiers dans l'arborescence (portion 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 10 +- backend/main.py | 127 ++++++++++++++++ frontend/js/app.js | 3 + frontend/js/dragdrop.js | 329 ++++++++++++++++++++++++++++++++++++++++ frontend/style.css | 31 ++++ 5 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 frontend/js/dragdrop.js diff --git a/ROADMAP.md b/ROADMAP.md index c76c6a2..943f9f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,9 +116,15 @@ - [x] **Fermeture** : bouton ✕ par onglet dans le switcher - [x] **Overlay** : tap à l'extérieur pour fermer le sheet -### 5. Drag & drop de fichiers +### ✅ 5. Drag & drop de fichiers — FAIT - **Effort** : 1-2 jours | **Impact** : 🟡 -- Déplacer/réorganiser dans l'arborescence +- Déplacer/réorganiser dans l'arborescence ✅ +- **Fichiers** : `backend/main.py` (endpoint `POST /api/move/{vault_name}`), `frontend/js/dragdrop.js`, `frontend/js/app.js`, `frontend/style.css` +- [x] **Endpoint backend** : `POST /api/move/{vault_name}` — déplace fichiers et dossiers vers un autre répertoire parent, même vault +- [x] **Drag & drop frontend** : glisser-déposer les `.tree-item` (fichiers et dossiers) vers les dossiers ou la racine d'un vault +- [x] **Indicateurs visuels** : opacité réduite sur l'élément source, outline en pointillé sur les cibles valides, surbrillance sur la cible active +- [x] **Protection** : pas de drop sur soi-même, pas de drop dans un enfant, pas de cross-vault +- [x] **SSE + index** : mise à jour de l'index et broadcast SSE après move ### 6. Diff viewer backups - **Effort** : 1 jour | **Impact** : 🟢 diff --git a/backend/main.py b/backend/main.py index 9b0f2fa..78cbae5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/frontend/js/app.js b/frontend/js/app.js index 2d4bbca..e299280 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -15,6 +15,7 @@ import { initGraphView } from './graph.js'; import * as Legacy from './legacy.js'; import { initCommandPalette } from './palette.js'; import { initMobileToolbar } from './mobile-toolbar.js'; +import { initDragDrop } from './dragdrop.js'; // ========================================================================= // Initialization — mirrors the original app.js init() ordering @@ -57,6 +58,8 @@ async function init() { try { await Promise.all([Legacy.loadVaultSettings(), Sidebar.loadVaults(), Sidebar.loadTags()]); + initDragDrop(); + Legacy.DashboardRecentWidget.init(); const urlParams = new URLSearchParams(window.location.search); diff --git a/frontend/js/dragdrop.js b/frontend/js/dragdrop.js new file mode 100644 index 0000000..8aeff60 --- /dev/null +++ b/frontend/js/dragdrop.js @@ -0,0 +1,329 @@ +/** + * Drag & Drop module for ObsiGate sidebar tree. + * + * Allows drag-and-drop of tree items (files and directories) onto + * target directories or vault roots to reorganize the vault. + * + * Usage: import { initDragDrop } from './dragdrop.js'; + * initDragDrop() after the vault tree is ready. + */ + +import { api } from './auth.js'; +import { showToast } from './ui.js'; +import { refreshSidebarTreePreservingState } from './sidebar.js'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/** Currently dragged item: { vault, path, type, element } */ +let dragState = null; + +// --------------------------------------------------------------------------- +// CSS classes applied during drag operations +// --------------------------------------------------------------------------- + +const DRAG_OVER_CLASS = 'drag-over'; +const DRAG_SOURCE_CLASS = 'drag-source'; +const DRAG_VALID_TARGET_CLASS = 'drag-valid-target'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Walk up the DOM to find the closest ancestor matching the selector. + */ +function closest(selector, el) { + while (el && el !== document) { + if (el.matches && el.matches(selector)) return el; + el = el.parentNode; + } + return null; +} + +/** + * Determine if an element is a valid drop target (directory or vault root). + * Returns { vault, targetDir } or null. + */ +function getDropTarget(el) { + // Directory tree item + const dirItem = closest('.tree-item[data-path]', el); + if (dirItem) { + // Only directories are valid targets + const icon = dirItem.querySelector('[data-lucide]'); + const isDir = icon && (icon.getAttribute('data-lucide') === 'folder' + || icon.getAttribute('data-lucide') === 'chevron-right' + || icon.getAttribute('data-lucide') === 'chevron-down'); + // Heuristic: if the tree item has a child container sibling, it's a directory + const hasChildren = dirItem.nextElementSibling + && dirItem.nextElementSibling.classList.contains('tree-children'); + if (isDir || hasChildren) { + return { + vault: dirItem.dataset.vault, + targetDir: dirItem.dataset.path, + }; + } + return null; + } + + // Vault item (root of a vault) + const vaultItem = closest('.tree-item.vault-item[data-vault]', el); + if (vaultItem) { + return { + vault: vaultItem.dataset.vault, + targetDir: '', // empty = vault root + }; + } + + return null; +} + +/** + * Highlight drop target and its parent chain. + */ +function highlightDropTarget(el) { + const target = getDropTarget(el); + if (!target) return; + + if (target.targetDir) { + // Highlight the directory + const dirEl = document.querySelector( + `.tree-item[data-vault="${CSS.escape(target.vault)}"][data-path="${CSS.escape(target.targetDir)}"]` + ); + if (dirEl) dirEl.classList.add(DRAG_OVER_CLASS); + } else { + // Highlight the vault + const vaultEl = document.querySelector(`.tree-item.vault-item[data-vault="${CSS.escape(target.vault)}"]`); + if (vaultEl) vaultEl.classList.add(DRAG_OVER_CLASS); + } +} + +/** + * Remove all drop target highlights. + */ +function clearAllHighlights() { + document.querySelectorAll(`.${DRAG_OVER_CLASS}`).forEach(el => el.classList.remove(DRAG_OVER_CLASS)); +} + +/** + * Check if the source item is being dropped on itself or inside itself. + */ +function isInvalidDrop(source, target) { + if (source.vault !== target.vault) return true; // cross-vault not supported + if (!target.targetDir) { + // Target is vault root — check if source is already at root + const sourceParent = source.path.includes('/') + ? source.path.substring(0, source.path.lastIndexOf('/')) + : ''; + return sourceParent === ''; + } + // Can't drop a directory into itself or its children + if (source.type === 'directory') { + if (source.path === target.targetDir) return true; // dropping on itself + if (target.targetDir.startsWith(source.path + '/')) return true; // dropping into a child + } + // Check if source is already in this directory + const sourceParent = source.path.includes('/') + ? source.path.substring(0, source.path.lastIndexOf('/')) + : ''; + if (sourceParent === target.targetDir) return true; // same directory + return false; +} + +// --------------------------------------------------------------------------- +// Event handlers +// --------------------------------------------------------------------------- + +function onDragStart(e) { + const treeItem = closest('.tree-item', e.target); + if (!treeItem) return; + + // Don't allow dragging vault items themselves + if (treeItem.classList.contains('vault-item')) return; + + const vault = treeItem.dataset.vault; + const path = treeItem.dataset.path; + if (!vault || path === undefined) return; + + // Determine type: directory or file + const hasChildren = treeItem.nextElementSibling + && treeItem.nextElementSibling.classList.contains('tree-children'); + const type = hasChildren ? 'directory' : 'file'; + + dragState = { vault, path, type, element: treeItem }; + + // Set drag data and visual feedback + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', path); + + // Visual: reduce opacity of source + treeItem.classList.add(DRAG_SOURCE_CLASS); + + // Add valid-target indicator to all directories and vaults + setTimeout(() => { + document.querySelectorAll('.tree-item').forEach(item => { + if (item === treeItem) return; + if (item.classList.contains('vault-item')) { + item.classList.add(DRAG_VALID_TARGET_CLASS); + } else { + const icon = item.querySelector('[data-lucide]'); + const hasChildContainer = item.nextElementSibling + && item.nextElementSibling.classList.contains('tree-children'); + if (icon && (icon.getAttribute('data-lucide') === 'folder' || hasChildContainer)) { + item.classList.add(DRAG_VALID_TARGET_CLASS); + } + } + }); + }, 0); +} + +function onDragOver(e) { + if (!dragState) return; + e.preventDefault(); // Required to allow drop + e.dataTransfer.dropEffect = 'move'; + + clearAllHighlights(); + + const target = getDropTarget(e.target); + if (!target || isInvalidDrop(dragState, target)) { + e.dataTransfer.dropEffect = 'none'; + return; + } + + highlightDropTarget(e.target); +} + +function onDragLeave(e) { + // Only clear when actually leaving the container, not entering a child + const container = document.getElementById('vault-tree'); + if (!container) return; + // Use a small delay to avoid flickering when moving between children + clearAllHighlights(); +} + +function onDragEnd() { + clearAllHighlights(); + + // Remove drag source visual + if (dragState && dragState.element) { + dragState.element.classList.remove(DRAG_SOURCE_CLASS); + } + + // Remove valid-target indicators + document.querySelectorAll(`.${DRAG_VALID_TARGET_CLASS}`).forEach(el => { + el.classList.remove(DRAG_VALID_TARGET_CLASS); + }); + + dragState = null; +} + +async function onDrop(e) { + e.preventDefault(); + e.stopPropagation(); + + if (!dragState) return; + + const target = getDropTarget(e.target); + if (!target || isInvalidDrop(dragState, target)) { + onDragEnd(); + return; + } + + const { vault, path, type } = dragState; + const { vault: targetVault, targetDir } = target; + + // Don't move if source and target are the same vault + if (vault !== targetVault) { + showToast('Cross-vault moves are not supported', 'error'); + onDragEnd(); + return; + } + + // Save drag state before cleanup + const savedState = { ...dragState }; + + // Clean up visual state immediately + onDragEnd(); + + // Call API + try { + const response = await api(`/api/move/${encodeURIComponent(vault)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_path: path, + destination_dir: targetDir, + }), + }); + + if (response && response.success) { + showToast(`Moved ${type} to ${targetDir || 'vault root'}`, 'success'); + // Refresh the tree preserving expanded state + await refreshSidebarTreePreservingState(); + } else { + showToast('Move failed', 'error'); + } + } catch (err) { + console.error('Drag & drop move failed:', err); + const detail = err?.detail || err?.message || 'Unknown error'; + showToast(`Move failed: ${detail}`, 'error'); + // Refresh anyway in case the filesystem state changed + try { await refreshSidebarTreePreservingState(); } catch (_) { /* ignore */ } + } +} + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +/** + * Attach drag-and-drop event listeners to the vault tree container. + * Uses event delegation on the #vault-tree element. + */ +export function initDragDrop() { + const container = document.getElementById('vault-tree'); + if (!container) { + console.warn('DragDrop: vault-tree container not found'); + return; + } + + // Remove previous listeners if any (idempotent) + container.removeEventListener('dragstart', onDragStart); + container.removeEventListener('dragover', onDragOver); + container.removeEventListener('dragleave', onDragLeave); + container.removeEventListener('dragend', onDragEnd); + container.removeEventListener('drop', onDrop); + + // Attach listeners with delegation + container.addEventListener('dragstart', onDragStart); + container.addEventListener('dragover', onDragOver); + container.addEventListener('dragleave', onDragLeave); + container.addEventListener('dragend', onDragEnd); + container.addEventListener('drop', onDrop); + + // Make all existing tree items draggable + makeAllDraggable(); + + // Re-apply draggable after any DOM mutation in the tree + const observer = new MutationObserver(() => { + makeAllDraggable(); + }); + observer.observe(container, { childList: true, subtree: true }); +} + +/** + * Make all tree items (files and directories) draggable. + * Vault items themselves are NOT draggable. + */ +function makeAllDraggable() { + const container = document.getElementById('vault-tree'); + if (!container) return; + + container.querySelectorAll('.tree-item').forEach(item => { + if (item.classList.contains('vault-item')) return; + if (!item.hasAttribute('draggable')) { + item.setAttribute('draggable', 'true'); + } + }); +} diff --git a/frontend/style.css b/frontend/style.css index b352a96..f8a5316 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -911,6 +911,37 @@ select { outline: none; } +/* --------------------------------------------------------------------------- + Drag & Drop — tree items + --------------------------------------------------------------------------- */ + +/* Source item being dragged */ +.tree-item.drag-source { + opacity: 0.35; + outline: 1px dashed var(--border); +} + +/* Valid drop targets during drag — subtle pulse indicator */ +.tree-item.drag-valid-target { + outline: 1px dashed color-mix(in srgb, var(--accent) 30%, transparent); + outline-offset: -1px; +} + +/* Active drop target (hovering over) */ +.tree-item.drag-over { + background: color-mix(in srgb, var(--accent) 18%, transparent) !important; + outline: 2px solid var(--accent); + outline-offset: -2px; + border-radius: 4px; +} + +/* Vault item as drop target */ +.tree-item.vault-item.drag-over { + background: color-mix(in srgb, var(--accent) 15%, transparent) !important; + outline: 2px solid var(--accent); + outline-offset: -2px; +} + .tree-children { padding-left: 16px; overflow: hidden;