// Persistent left navigation rail. Renders the wiki folder/file tree, expanded // to the current page's ancestor chain, and lets the user navigate by clicking // folders (links) or open files locally via the companion (a.tree-file, wired // in companion.js). Shares the .tree-* CSS and the ?tree endpoint with // tree-picker.js but does not reuse its row builder — that one is modal-select // behavior, this one is navigation behavior. Hidden in edit mode (the server // omits the container). (function () { var container = document.querySelector('aside.tree-sidebar'); if (!container) return; function joinPath(parent, name) { if (parent === '/' || parent === '') return '/' + name; return parent.replace(/\/+$/, '') + '/' + name; } function encodeSegments(p) { return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); } function folderHref(p) { if (p === '/' || p === '') return '/'; return '/' + encodeSegments(p) + '/'; } function fileHref(p) { return '/' + encodeSegments(p); } function fetchFolder(path, expandTo) { var url = folderHref(path) + '?tree=1'; if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo); return fetch(url, { credentials: 'same-origin' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } // Current page as a canonical, DECODED wiki path ("/" for root, no trailing // slash otherwise). pathname is percent-encoded; the tree's entry names (and // thus fullPath) are decoded, so we must decode here too or paths containing // spaces / non-ASCII never match a row and the chain never expands. var activePath = (function () { var p = window.location.pathname.replace(/\/+$/, ''); if (p === '') return '/'; try { return decodeURIComponent(p); } catch (e) { return p; } })(); var activeRow = null; function disabledRow(text) { var row = document.createElement('div'); row.className = 'tree-row is-disabled'; row.textContent = text; return row; } // renderInto fills containerEl with rows for `entries` (children of // parentPath). Folders carrying a `children` array (the pre-expanded // ancestor chain from ?expandTo) are opened immediately and recurse. function renderInto(containerEl, parentPath, entries) { if (!entries || entries.length === 0) { containerEl.appendChild(disabledRow('(empty)')); return; } entries.forEach(function (entry) { var built = buildRow(parentPath, entry); containerEl.appendChild(built.row); if (built.preExpand) built.open(); }); } function buildRow(parentPath, entry) { var fullPath = joinPath(parentPath, entry.name); var isFolder = entry.kind === 'folder'; var row = document.createElement('div'); row.className = 'tree-row'; var chevron = document.createElement('span'); chevron.className = 'tree-chevron'; if (isFolder) chevron.textContent = '▸'; // ▸ else chevron.classList.add('is-leaf'); row.appendChild(chevron); var link = document.createElement('a'); link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file'); link.textContent = entry.name; link.href = isFolder ? folderHref(fullPath) : fileHref(fullPath); row.appendChild(link); if (isFolder && fullPath === activePath) { row.classList.add('is-active'); activeRow = row; } var preChildren = (isFolder && entry.children) ? entry.children : null; var childrenEl = null; var loaded = false; var isOpen = false; function ensureChildrenEl() { if (!childrenEl) { childrenEl = document.createElement('div'); childrenEl.className = 'tree-children'; } return childrenEl; } function loadChildren() { var el = ensureChildrenEl(); el.textContent = ''; el.appendChild(disabledRow('…')); fetchFolder(fullPath).then(function (resp) { el.textContent = ''; renderInto(el, fullPath, resp.entries); loaded = true; }).catch(function () { el.textContent = ''; var err = document.createElement('div'); err.className = 'tree-row'; err.textContent = '(failed — tap to retry)'; err.addEventListener('click', function (e) { e.stopPropagation(); loadChildren(); }); el.appendChild(err); }); } function open() { if (!isFolder || isOpen) return; var el = ensureChildrenEl(); row.parentNode.insertBefore(el, row.nextSibling); chevron.textContent = '▾'; // ▾ isOpen = true; if (!loaded) { if (preChildren) { renderInto(el, fullPath, preChildren); loaded = true; } else { loadChildren(); } } } function close() { if (!isFolder || !isOpen) return; if (childrenEl && childrenEl.parentNode) { childrenEl.parentNode.removeChild(childrenEl); } chevron.textContent = '▸'; isOpen = false; } // Clicking the name link navigates (folder) or opens the file; clicking // anywhere else on the row toggles expansion for folders. row.addEventListener('click', function (e) { if (e.target.closest('a.tree-name')) return; if (!isFolder) return; if (isOpen) close(); else open(); }); return { row: row, open: open, preExpand: !!preChildren }; } function render() { container.textContent = ''; var listing = document.createElement('div'); container.appendChild(listing); listing.appendChild(disabledRow('…')); fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) { listing.textContent = ''; renderInto(listing, '/', resp.entries); if (activeRow) { try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {} } }).catch(function () { listing.textContent = ''; var err = document.createElement('div'); err.className = 'tree-row'; err.textContent = '(failed — tap to retry)'; err.addEventListener('click', function () { render(); }); listing.appendChild(err); }); } // Mobile: the tree FAB sits at the bottom of the stacked FAB group and // opens the rail's content in the full-viewport Overlay (overlay.js). The // rail container always exists when not editing, so — per the layout spec — // the FAB always renders; the overlay auto-closes when a folder/file link // inside it navigates. Hidden on desktop by .fab CSS. function setupFab() { var fab = document.createElement('button'); fab.type = 'button'; fab.className = 'btn btn-fab fab fab-tree'; fab.title = 'Folder tree'; fab.setAttribute('aria-label', 'Folder tree'); fab.innerHTML = ''; fab.addEventListener('click', function () { if (typeof openOverlay === 'function') openOverlay(container); }); document.body.appendChild(fab); } render(); setupFab(); })();