diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e8a884..6a53660 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,61 @@
# Changelog
+## Changes 11/13/2025 (v1.9.4)
+
+release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
+
+**Big focus on folder management performance & UX for large libraries.**
+
+feat(folder-tree):
+
+- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
+- BFS-based initial selection: if user can’t view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
+- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
+- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
+- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
+- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
+- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
+- Root DnD honored only when viewable; structural locks disable dragging.
+
+perf(core):
+
+- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
+- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
+- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
+
+api/model:
+
+- `FolderModel::listChildren(...)` now returns items shaped like:
+ `{ name, locked, hasSubfolders, nonEmpty? }`
+ - `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
+ - Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
+- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
+- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
+
+ui/ux:
+
+- New “Load more” row (`
`;
- if (hasChildren) {
- html += ``;
- } else {
- html += ``;
- }
- html += `
-
-
- ${folderSVG(hasChildren ? 'paper' : 'empty')}
-
- ${escapeHTML(folder)}
-
-`;
- html += `
`; // /.folder-row
-
- if (hasChildren) html += renderFolderTree(tree[folder], fullPath, displayState);
- html += `
`;
}
- html += `
`;
- return html;
+ return li;
}
-
-function expandTreePath(path, opts = {}) {
- const { force = false, persist = false, includeLeaf = false } = opts;
- const state = loadFolderTreeState();
- const parts = (path || '').split('/').filter(Boolean);
- let cumulative = '';
-
- const lastIndex = includeLeaf ? parts.length - 1 : Math.max(0, parts.length - 2);
-
- parts.forEach((part, i) => {
- cumulative = i === 0 ? part : `${cumulative}/${part}`;
- if (i > lastIndex) return; // skip leaf unless asked
-
- const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
- if (!option) return;
-
- const li = option.closest('li[role="treeitem"]');
- const nestedUl = li ? li.querySelector(':scope > ul') : null;
- if (!nestedUl) return;
-
- const shouldExpand = force || state[cumulative] === 'block';
- nestedUl.classList.toggle('expanded', shouldExpand);
- nestedUl.classList.toggle('collapsed', !shouldExpand);
- li.setAttribute('aria-expanded', String(!!shouldExpand));
-
- if (persist && shouldExpand) {
- state[cumulative] = 'block';
- }
- });
-
- if (persist) saveFolderTreeState(state);
-}
-
+function folderDragOverHandler(event) { event.preventDefault(); event.currentTarget.classList.add("drop-hover"); }
+function folderDragLeaveHandler(event) { event.currentTarget.classList.remove("drop-hover"); }
/* ----------------------
- Drag & Drop Support for Folder Tree Nodes
+ Color-carry helper (fix #2)
----------------------*/
-function folderDragOverHandler(event) {
- event.preventDefault();
- event.currentTarget.classList.add("drop-hover");
+async function carryFolderColor(sourceFolder, newPath) {
+ const oldColor = window.folderColorMap[sourceFolder];
+ if (!oldColor) return;
+ try {
+ await saveFolderColor(newPath, oldColor);
+ await saveFolderColor(sourceFolder, '');
+ } catch {}
}
-function folderDragLeaveHandler(event) {
- event.currentTarget.classList.remove("drop-hover");
-}
-
-function folderDropHandler(event) {
+/* ----------------------
+ Handle drop (files or folders)
+----------------------*/
+function handleDropOnFolder(event, dropFolder) {
event.preventDefault();
- event.currentTarget.classList.remove("drop-hover");
- const dropFolder = event.currentTarget.getAttribute("data-folder");
+ event.currentTarget?.classList?.remove("drop-hover");
let dragData = null;
try {
const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr);
- } catch (e) {
- console.error("Invalid drag data", e);
- return;
- }
+ } catch { /* noop */ }
- // FOLDER MOVE FALLBACK (folder->folder)
+ // FOLDER->FOLDER move fallback
if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
const sourceFolder = String(plain || "").trim();
if (!sourceFolder || sourceFolder === "root") return;
-
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
- showToast("Invalid destination.", 4000);
- return;
+ showToast("Invalid destination.", 4000); return;
}
+ // snapshot current expansion state (to re-apply later)
+ const preState = loadFolderTreeState();
+
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
- })
- .then(safeJson)
- .then(data => {
- if (data && !data.error) {
- showToast(`Folder moved to ${dropFolder}!`);
+ }).then(safeJson).then(async (data) => {
+ if (data && !data.error) {
+ const base = sourceFolder.split("/").pop();
+ const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
- if (window.currentFolder &&
- (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
- const base = sourceFolder.split("/").pop();
- const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
+ // carry color
+ await carryFolderColor(sourceFolder, newPath);
- // carry color without await
- const oldColor = window.folderColorMap[sourceFolder];
- if (oldColor) {
- saveFolderColor(newPath, oldColor)
- .then(() => saveFolderColor(sourceFolder, ''))
- .catch(() => { });
- }
+ // migrate expansion + keep dest open
+ migrateExpansionStateOnMove(sourceFolder, newPath, [dropFolder, getParentFolder(dropFolder)]);
+ // refresh parents (incremental)
+ const srcParent = getParentFolder(sourceFolder);
+ const dstParent = dropFolder;
+ invalidateFolderCaches(srcParent);
+ invalidateFolderCaches(dstParent);
+ clearPeekCache([srcParent, dstParent, sourceFolder, newPath]);
+
+ const srcUl = getULForFolder(srcParent);
+ const dstUl = getULForFolder(dstParent);
+ if (srcUl) { srcUl._renderedOnce = false; srcUl.innerHTML = ""; await ensureChildrenLoaded(srcParent, srcUl); }
+ if (dstUl) { dstUl._renderedOnce = false; dstUl.innerHTML = ""; await ensureChildrenLoaded(dstParent, dstUl); }
+
+ // destination now definitely has a child folder → force chevron immediately
+ updateToggleForOption(dstParent, true);
+ ensureFolderIcon(dstParent);
+
+ // source may have lost its last child folder → recompute from the live DOM
+ const _srcUlLive = getULForFolder(srcParent);
+ updateToggleForOption(srcParent, !!(_srcUlLive && _srcUlLive.querySelector(':scope > li.folder-item')));
+
+ // re-apply all saved expansions so nothing "closes"
+ await expandAndLoadSavedState();
+
+ // update selection/current folder (if you were inside moved subtree)
+ if (window.currentFolder) {
+ if (window.currentFolder === sourceFolder) {
window.currentFolder = newPath;
+ } else if (window.currentFolder.startsWith(sourceFolder + "/")) {
+ const suffix = window.currentFolder.slice(sourceFolder.length); // includes leading '/'
+ window.currentFolder = newPath + suffix;
}
-
- return loadFolderTree().then(() => {
- try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
- loadFileList(window.currentFolder || "root");
- });
- } else {
- showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
+ localStorage.setItem("lastOpenedFolder", window.currentFolder);
}
- })
- .catch(err => {
- console.error("Error moving folder:", err);
- showToast("Error moving folder", 5000);
- });
+ // icons + breadcrumb + file list
+ refreshFolderIcon(srcParent);
+ refreshFolderIcon(dstParent);
+ showToast(`Folder moved to ${dropFolder}!`);
+ updateBreadcrumbTitle(window.currentFolder || newPath);
+ loadFileList(window.currentFolder || newPath);
+
+ // ensure the moved node is visible & selected
+ selectFolder(window.currentFolder || newPath);
+
+ } else {
+ showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
+ }
+ }).catch(err => {
+ console.error("Error moving folder:", err);
+ showToast("Error moving folder", 5000);
+ });
return;
}
- // File(s) drop path (unchanged)
+ // File(s) move
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return;
@@ -914,85 +1181,135 @@ function folderDropHandler(event) {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
- body: JSON.stringify({
- source: dragData.sourceFolder,
- files: filesToMove,
- destination: dropFolder
- })
- })
- .then(safeJson)
- .then(data => {
- if (data.success) {
- showToast(`File(s) moved successfully to ${dropFolder}!`);
- loadFileList(dragData.sourceFolder);
- refreshFolderIcon(dragData.sourceFolder);
- refreshFolderIcon(dropFolder);
- } else {
- showToast("Error moving files: " + (data.error || "Unknown error"));
- }
- })
- .catch(error => {
- console.error("Error moving files via drop:", error);
- showToast("Error moving files.");
- });
+ body: JSON.stringify({ source: dragData.sourceFolder, files: filesToMove, destination: dropFolder })
+ }).then(safeJson).then(data => {
+ if (data.success) {
+ showToast(`File(s) moved successfully to ${dropFolder}!`);
+ refreshFolderIcon(dragData.sourceFolder);
+ refreshFolderIcon(dropFolder);
+ loadFileList(dragData.sourceFolder);
+ } else {
+ showToast("Error moving files: " + (data.error || "Unknown error"));
+ }
+ }).catch(() => showToast("Error moving files."));
}
/* ----------------------
- Main Folder Tree Rendering and Event Binding
+ Selection + helpers
----------------------*/
-// Safe breadcrumb DOM builder
-function renderBreadcrumbFragment(folderPath) {
- const frag = document.createDocumentFragment();
+function getULForFolder(folder) {
+ if (folder === 'root') return document.getElementById('rootChildren');
+ const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
+ const li = opt ? opt.closest('li[role="treeitem"]') : null;
+ return li ? li.querySelector(':scope > ul.folder-tree') : null;
+}
+async function selectFolder(selected) {
+ const container = document.getElementById('folderTreeContainer');
+ if (!container) return;
- // Defensive normalize
- const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
- const crumbs = path.split('/').filter(s => s !== ''); // no empty segments
-
- let acc = '';
- for (let i = 0; i < crumbs.length; i++) {
- const part = crumbs[i];
- acc = (i === 0) ? part : (acc + '/' + part);
-
- const span = document.createElement('span');
- span.className = 'breadcrumb-link';
- span.dataset.folder = acc;
- span.textContent = part;
- frag.appendChild(span);
-
- if (i < crumbs.length - 1) {
- const sep = document.createElement('span');
- sep.className = 'file-breadcrumb-sep';
- sep.textContent = '›';
- frag.appendChild(sep);
+ // If the node is in the tree, trust its locked class.
+ let opt = container.querySelector(`.folder-option[data-folder="${CSS.escape(selected)}"]`);
+ let allowed = true;
+ applyFolderCapabilities(selected);
+ if (opt && opt.classList.contains('locked')) {
+ allowed = false;
+ } else if (!opt) {
+ // Not in DOM → preflight capabilities so breadcrumbs (and other callers)
+ // can't jump into forbidden folders.
+ try {
+ allowed = await canViewFolder(selected);
+ } catch {
+ allowed = false;
}
}
- return frag;
+ if (!allowed) {
+ showToast(t('no_access') || "You do not have access to this resource.");
+ return; // do NOT change currentFolder or lastOpenedFolder
+ }
+
+ // At this point we’re allowed. If the node isn’t visible yet, open its parents
+ // so the tree reflects where we are going.
+ if (!opt && selected && selected !== 'root') {
+ const parts = selected.split('/').filter(Boolean);
+ const st = loadFolderTreeState();
+ let acc = '';
+ for (let i = 0; i < parts.length; i++) {
+ acc = i === 0 ? parts[i] : `${acc}/${parts[i]}`;
+ st[acc] = 'block';
+ }
+ saveFolderTreeState(st);
+ // Materialize the opened branches
+ await expandAndLoadSavedState();
+ opt = container.querySelector(`.folder-option[data-folder="${CSS.escape(selected)}"]`);
+ }
+
+ // Visual selection
+ container.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
+ if (opt) opt.classList.add("selected");
+
+ // Update state + UI
+ window.currentFolder = selected;
+ localStorage.setItem("lastOpenedFolder", selected);
+ updateBreadcrumbTitle(selected);
+ applyFolderCapabilities(selected);
+ ensureFolderIcon(selected);
+ loadFileList(selected);
+
+ // Expand the selected node’s UL if present
+ const ul = getULForFolder(selected);
+ if (ul) {
+ ul.classList.add('expanded');
+ ul.classList.remove('collapsed');
+ const parentLi = selected === 'root'
+ ? document.getElementById('rootRow')
+ : (opt ? opt.closest('li[role="treeitem"]') : null);
+ if (parentLi) parentLi.setAttribute('aria-expanded', 'true');
+
+ const st = loadFolderTreeState();
+ st[selected] = 'block';
+ saveFolderTreeState(st);
+ try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
+ }
}
-export function updateBreadcrumbTitle(folder) {
- const titleEl = document.getElementById("fileListTitle");
- if (!titleEl) return;
- titleEl.textContent = "";
- titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
- titleEl.appendChild(renderBreadcrumbFragment(folder));
- titleEl.appendChild(document.createTextNode(")"));
- setupBreadcrumbDelegation();
- // Ensure context menu delegation is hooked to the dynamic breadcrumb container
- bindFolderManagerContextMenu();
+/* ----------------------
+ Expand saved state at boot
+----------------------*/
+async function expandAndLoadSavedState() {
+ const st = loadFolderTreeState();
+ const openKeys = Object.keys(st).filter(k => st[k] === 'block');
+ openKeys.sort((a, b) => a.split('/').length - b.split('/').length);
+
+ for (const key of openKeys) {
+ const ul = getULForFolder(key);
+ if (!ul) continue;
+ ul.classList.add('expanded');
+ ul.classList.remove('collapsed');
+
+ let li;
+ if (key === 'root') {
+ li = document.getElementById('rootRow');
+ } else {
+ const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(key)}"]`);
+ li = opt ? opt.closest('li[role="treeitem"]') : null;
+ }
+ if (li) li.setAttribute('aria-expanded', 'true');
+ try { await ensureChildrenLoaded(key, ul); } catch {}
+ }
}
+/* ----------------------
+ Main: loadFolderTree
+----------------------*/
export async function loadFolderTree(selectedFolder) {
try {
- // Check if the user has folder-only permission (server-authoritative).
await checkUserFolderPermission();
-
- // Determine effective root folder.
const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root";
let effectiveLabel = "(Root)";
if (window.userFolderOnly && username) {
- effectiveRoot = username; // personal root
+ effectiveRoot = username;
effectiveLabel = `(Root)`;
localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username;
@@ -1000,511 +1317,201 @@ export async function loadFolderTree(selectedFolder) {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
}
- // Fetch folder list from the server (server enforces scope).
- const res = await fetchWithCsrf('/api/folder/getFolderList.php', {
- method: 'GET',
- credentials: 'include'
- });
-
- if (res.status === 401) {
- showToast("Session expired. Please log in again.");
- window.location.href = "/api/auth/logout.php";
- return;
- }
- if (res.status === 403) {
- showToast("You don't have permission to view folders.");
- return;
- }
-
- const folderData = await safeJson(res);
-
- let folders = [];
- if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
- folders = folderData.map(item => item.folder);
- } else if (Array.isArray(folderData)) {
- folders = folderData;
- }
-
- // Remove any global "root" entry (server shouldn't return it, but be safe).
- folders = folders.filter(folder => folder.toLowerCase() !== "root");
-
- // If restricted, filter client-side view to subtree for UX (server still enforces).
- if (window.userFolderOnly && effectiveRoot !== "root") {
- folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
- localStorage.setItem("lastOpenedFolder", effectiveRoot);
- window.currentFolder = effectiveRoot;
- }
-
- localStorage.setItem("lastOpenedFolder", window.currentFolder);
-
- // Render the folder tree.
const container = document.getElementById("folderTreeContainer");
- if (!container) {
- console.error("Folder tree container not found.");
- return;
- }
+ if (!container) return;
const state0 = loadFolderTreeState();
const rootOpen = state0[effectiveRoot] !== 'none';
let html = `
-
-
-
-
- ${effectiveLabel}
-
-
-`;
-
- if (folders.length > 0) {
- const tree = buildFolderTree(folders);
- // 👇 pass the root's saved state down to first level
- html += renderFolderTree(tree, "", rootOpen ? "block" : "none");
- }
+