Update README docker-compose syntax and add incremental sidebar refresh

Implement `incrementalLoadDirectory` to update tree items without full
DOM
rebuild. Modify `refreshSidebarTreePreservingState` to use incremental
updates for expanded vaults and directories, preserving existing DOM
state.
This commit is contained in:
Bruno Charest 2026-05-25 22:05:40 -04:00
parent 9ce95eda2d
commit c65063fbca
2 changed files with 140 additions and 13 deletions

View File

@ -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.

View File

@ -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);
}