diff --git a/README.md b/README.md index c8508f6..22d70a7 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,10 @@ volumes: ```bash # Build local de l'image + démarrage -docker-compose up -d --build +docker compose up -d --build # Vérifier les logs -docker-compose logs -f obsigate +docker compose logs -f obsigate ``` > **Note** : ObsiGate est construit localement depuis le `Dockerfile` du projet. Sans build local, Docker essaiera de télécharger une image distante `obsigate:latest` qui n'existe pas forcément. diff --git a/frontend/app.js b/frontend/app.js index aafe426..2052f12 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2244,6 +2244,132 @@ * Refreshes the sidebar tree while preserving the expanded state of vaults and folders. * Optimized to avoid a full sidebar wipe and minimize visible loading states. */ + /** + * Incrementally update a directory container without wiping existing DOM. + * Only adds new items, removes deleted ones, and updates changed ones. + */ + async function incrementalLoadDirectory(vaultName, dirPath, container) { + let data; + try { + const url = `/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(dirPath)}`; + data = await api(url); + } catch (err) { + // Server unavailable — keep existing content + return; + } + + // Build a map of existing DOM elements by path + const existingItems = {}; + const existingChildren = {}; // path -> child container (for directories) + for (let i = 0; i < container.children.length; i++) { + const child = container.children[i]; + if (child.classList.contains("tree-item") && child.dataset.path) { + existingItems[child.dataset.path] = child; + // The next sibling should be the tree-children container for this directory + if (i + 1 < container.children.length) { + const next = container.children[i + 1]; + if (next.classList.contains("tree-children")) { + existingChildren[child.dataset.path] = next; + } + } + } + } + + const fragment = document.createDocumentFragment(); + + data.items.forEach((item) => { + if (!shouldDisplayPath(item.path, vaultName)) return; + + const existing = existingItems[item.path]; + + if (existing) { + // Item already exists — reuse it, but update text/badge if needed + const textEl = existing.querySelector(".tree-item-text"); + const displayName = item.type === "file" && item.name.match(/\.md$/i) + ? item.name.replace(/\.md$/i, "") + : item.name; + if (textEl && textEl.textContent !== displayName) { + textEl.textContent = displayName; + } + // Update badge for directories + if (item.type === "directory") { + const badge = existing.querySelector(".badge-small"); + const newBadge = `(${item.children_count})`; + if (badge && badge.textContent !== newBadge) { + badge.textContent = newBadge; + } else if (!badge) { + existing.appendChild(smallBadge(item.children_count)); + } + } + fragment.appendChild(existing); + // Also re-add the child container for directories + if (item.type === "directory" && existingChildren[item.path]) { + fragment.appendChild(existingChildren[item.path]); + } else if (item.type === "directory") { + // Directory existed but no child container — create one + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + } + } else { + // New item — create it + if (item.type === "directory") { + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); + fragment.appendChild(dirItem); + + const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); + fragment.appendChild(subContainer); + + dirItem.addEventListener("click", async () => { + scrollTreeItemIntoView(dirItem, false); + if (subContainer.classList.contains("collapsed")) { + if (subContainer.children.length === 0) { + await loadDirectory(vaultName, item.path, subContainer); + } + subContainer.classList.remove("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + subContainer.classList.add("collapsed"); + const chev = dirItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + }); + + dirItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false); + }); + } else { + const fileIconName = getFileIcon(item.name); + const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); + fileItem.addEventListener("click", () => { + scrollTreeItemIntoView(fileItem, false); + openFile(vaultName, item.path); + closeMobileSidebar(); + }); + + fileItem.addEventListener("contextmenu", (e) => { + e.preventDefault(); + ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false); + }); + + fragment.appendChild(fileItem); + } + } + }); + + // Replace container content in a single batch operation to avoid flash + container.textContent = ""; + container.appendChild(fragment); + } + async function refreshSidebarTreePreservingState() { // 1. Capture expanded states const expandedVaults = Array.from(document.querySelectorAll(".vault-item")) @@ -2265,7 +2391,7 @@ const selectedItem = document.querySelector(".tree-item.path-selected"); const selectedState = selectedItem ? { vault: selectedItem.dataset.vault, path: selectedItem.dataset.path } : null; - // 2. Soft update: load vaults to update names/counts without wiping the tree + // 2. Soft update: vault names/counts without wiping the tree try { const vaults = await api("/api/vaults"); allVaults = vaults; @@ -2279,29 +2405,30 @@ } catch (e) { console.warn("Soft vault refresh failed, falling back to full reload", e); await loadVaults(); + return; } - // 3. Refresh expanded vaults - // If we didn't wipe the tree, we only need to call loadDirectory to update the children + // 3. Incrementally update expanded vaults (no DOM wipe) for (const vName of expandedVaults) { const container = document.getElementById(`vault-children-${vName}`); if (container) { - await loadDirectory(vName, "", container); + await incrementalLoadDirectory(vName, "", container); } } - // 4. Re-expand directories (parents first) + // 4. Incrementally update expanded directories (parents first, no DOM wipe) expandedDirs.sort((a, b) => a.path.split("/").length - b.path.split("/").length); for (const dir of expandedDirs) { - const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`); const container = document.getElementById(`dir-${dir.vault}-${dir.path}`); - if (dItem && container) { - // If it was already expanded but currently has its old content, loadDirectory will update it + if (container) { try { - await loadDirectory(dir.vault, dir.path, container); + await incrementalLoadDirectory(dir.vault, dir.path, container); container.classList.remove("collapsed"); - const chev = dItem.querySelector("[data-lucide]"); - if (chev) chev.setAttribute("data-lucide", "chevron-down"); + const dItem = document.querySelector(`.tree-item[data-vault="${CSS.escape(dir.vault)}"][data-path="${CSS.escape(dir.path)}"]`); + if (dItem) { + const chev = dItem.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + } } catch (e) { console.error(`Failed to refresh directory ${dir.vault}/${dir.path}`, e); }