Render the folder tree on the server
The sidebar tree is part of the page (chain to the current page expanded, active row marked), so it no longer paints late and the sessionStorage snapshot is gone; only the rail's scroll position is kept. Folders are native <details> that lazy-load their children as HTML via htmx. The tree picker hosts the same server-rendered rows with radio inputs, so ?tree returns HTML and the JSON listing is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+14
-15
@@ -17,8 +17,7 @@
|
||||
<script src="{{asset "search-suggest.js"}}" defer></script>
|
||||
<script src="{{asset "tree-picker.js"}}"></script>
|
||||
<script src="{{asset "companion.js"}}" defer></script>
|
||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
|
||||
<script src="{{asset "tree-sidebar.js"}}" defer></script>{{end}}
|
||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>{{end}}
|
||||
{{block "headScripts" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
@@ -37,7 +36,9 @@
|
||||
<span class="dropdown actions">
|
||||
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||
<div class="dropdown-menu align-right actions-menu">
|
||||
<div class="actions-special"></div>
|
||||
<div class="actions-special">
|
||||
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
||||
</div>
|
||||
{{template "headerActions" . }}
|
||||
</div>
|
||||
</span>
|
||||
@@ -46,20 +47,18 @@
|
||||
</header>
|
||||
<div class="shell">
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
<div class="tree-scroll"></div>
|
||||
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
|
||||
<script>
|
||||
// Paint the previous page's tree before first render so the rail
|
||||
// doesn't flash empty; tree-sidebar.js swaps in the live tree.
|
||||
// Keep the rail's scroll position across page loads (before
|
||||
// first paint, so it doesn't jump), then reveal the active row.
|
||||
(function () {
|
||||
try {
|
||||
var s = JSON.parse(sessionStorage.getItem("tree-snapshot"));
|
||||
if (!s) return;
|
||||
var el = document.currentScript.previousElementSibling;
|
||||
el.innerHTML = s.html;
|
||||
var a = el.querySelector('a.tree-name[href="' + CSS.escape(location.pathname.replace(/\/*$/, "/")) + '"]');
|
||||
if (a) a.parentNode.classList.add("is-active");
|
||||
el.scrollTop = s.top;
|
||||
} catch (e) {}
|
||||
var el = document.currentScript.previousElementSibling;
|
||||
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
|
||||
var active = el.querySelector(".tree-row.is-active");
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
addEventListener("pagehide", function () {
|
||||
try { sessionStorage.setItem("tree-scroll", el.scrollTop); } catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</aside>{{end}}
|
||||
|
||||
+21
-5
@@ -747,17 +747,33 @@ aside.sidebar:empty { display: none; }
|
||||
padding: 0.25rem var(--space-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
|
||||
.tree-row.is-selected {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(var(--space-2) - 3px);
|
||||
}
|
||||
.tree-row:hover { background: var(--bg-panel-hover); }
|
||||
.tree-row.is-active > .tree-name { color: var(--secondary); }
|
||||
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
|
||||
.tree-row.is-disabled:hover { background: none; }
|
||||
.tree-chevron { text-align: center; flex-shrink: 0; }
|
||||
.tree-chevron { width: 1.25rem; color: var(--secondary); }
|
||||
.tree-chevron.is-leaf { visibility: hidden; }
|
||||
/* Expandable folders are <details>; the chevron follows its open state. */
|
||||
.tree-node > summary { list-style: none; }
|
||||
.tree-node > summary::-webkit-details-marker { display: none; }
|
||||
.tree-node > summary > .tree-chevron::before { content: "\25b8"; }
|
||||
.tree-node[open] > summary > .tree-chevron::before { content: "\25be"; }
|
||||
/* Picker rows select through a visually hidden radio in the name label. */
|
||||
.tree-name input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
.tree-row:has(input:checked) {
|
||||
background: var(--bg-panel-hover);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(var(--space-2) - 3px);
|
||||
}
|
||||
.tree-row:has(input:focus-visible) { outline: 1px solid var(--secondary); }
|
||||
label.tree-name { flex: 1; cursor: pointer; position: relative; }
|
||||
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tree-children {
|
||||
margin-left: var(--space-2);
|
||||
|
||||
+31
-247
@@ -1,43 +1,29 @@
|
||||
// openTreePicker shows a modal to pick a wiki path. The tree is server-rendered
|
||||
// (?tree&pick=…, see tree.go and tree.html): each selectable row carries a
|
||||
// radio named "pick", folders expand natively and lazy-load via htmx. This
|
||||
// file only hosts the tree in a modal and reports the checked row.
|
||||
//
|
||||
// opts: title, confirmLabel, mode ('folder' | 'file' | 'any'), initialPath
|
||||
// (expanded on open), preselect (checked on open), hideFiles, allowRoot
|
||||
// (default true: offer "/" in folder modes), onSelect(path, kind).
|
||||
(function () {
|
||||
function joinPath(parent, name) {
|
||||
if (parent === '/' || parent === '') return '/' + name;
|
||||
return parent.replace(/\/+$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
function encodePath(p) {
|
||||
if (p === '/' || p === '') return '/';
|
||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
|
||||
}
|
||||
|
||||
function fetchFolder(path) {
|
||||
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function openTreePicker(opts) {
|
||||
opts = opts || {};
|
||||
var mode = opts.mode || 'folder';
|
||||
var initialPath = opts.initialPath || '/';
|
||||
var allowRoot = opts.allowRoot !== false;
|
||||
var hideFiles = !!opts.hideFiles;
|
||||
var preselect = opts.preselect || null;
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var treeEl = document.createElement('div');
|
||||
treeEl.className = 'tree-picker panel';
|
||||
|
||||
treeEl.textContent = '…';
|
||||
var selectedPathEl = document.createElement('div');
|
||||
selectedPathEl.className = 'tree-selected-path muted';
|
||||
selectedPathEl.textContent = '\u00a0';
|
||||
|
||||
selectedPathEl.textContent = ' ';
|
||||
container.appendChild(treeEl);
|
||||
container.appendChild(selectedPathEl);
|
||||
|
||||
var selected = null; // { path, kind, rowEl }
|
||||
function checked() {
|
||||
return treeEl.querySelector('input[name="pick"]:checked');
|
||||
}
|
||||
|
||||
var handle = openModal({
|
||||
title: opts.title || 'Pick',
|
||||
@@ -46,236 +32,34 @@
|
||||
label: opts.confirmLabel || 'SELECT',
|
||||
initiallyDisabled: true,
|
||||
onConfirm: function () {
|
||||
if (!selected) return;
|
||||
var pick = checked();
|
||||
if (!pick) return;
|
||||
handle.close();
|
||||
if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
|
||||
if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setSelected(path, kind, rowEl) {
|
||||
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
|
||||
selected = { path: path, kind: kind, rowEl: rowEl };
|
||||
rowEl.classList.add('is-selected');
|
||||
selectedPathEl.textContent = path;
|
||||
handle.setConfirmDisabled(false);
|
||||
function sync() {
|
||||
var pick = checked();
|
||||
selectedPathEl.textContent = pick ? pick.value : ' ';
|
||||
handle.setConfirmDisabled(!pick);
|
||||
}
|
||||
treeEl.addEventListener('change', sync);
|
||||
|
||||
function isSelectable(kind) {
|
||||
if (mode === 'any') return true;
|
||||
if (mode === 'folder') return kind === 'folder';
|
||||
if (mode === 'file') return kind === 'file';
|
||||
return false;
|
||||
}
|
||||
|
||||
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
|
||||
// for files and idempotent for folders (resolves with the already-
|
||||
// loaded children on repeat calls).
|
||||
function buildRow(parentPath, name, kind) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
if (!isSelectable(kind)) row.classList.add('is-disabled');
|
||||
|
||||
var chevron = document.createElement('span');
|
||||
chevron.className = 'tree-chevron';
|
||||
if (kind === 'folder') {
|
||||
chevron.textContent = '\u25b8'; // ▸
|
||||
} else {
|
||||
chevron.classList.add('is-leaf');
|
||||
var url = '/?tree&pick=' + mode +
|
||||
(opts.hideFiles ? '' : '&files=1') +
|
||||
(opts.allowRoot !== false ? '&root=1' : '') +
|
||||
'&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
|
||||
(opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
|
||||
htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
|
||||
sync();
|
||||
var pick = checked();
|
||||
if (pick) {
|
||||
try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
}
|
||||
row.appendChild(chevron);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'tree-name';
|
||||
label.textContent = name;
|
||||
row.appendChild(label);
|
||||
|
||||
var fullPath = joinPath(parentPath, name);
|
||||
var childrenEl = null;
|
||||
var loadPromise = null;
|
||||
var isOpen = false;
|
||||
|
||||
function expand() {
|
||||
if (kind !== 'folder') return Promise.resolve(null);
|
||||
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement('div');
|
||||
childrenEl.className = 'tree-children';
|
||||
}
|
||||
row.parentNode.insertBefore(childrenEl, row.nextSibling);
|
||||
chevron.textContent = '\u25be'; // ▾
|
||||
isOpen = true;
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadInto(fullPath, childrenEl);
|
||||
}
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
if (kind !== 'folder' || !isOpen) return;
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl);
|
||||
}
|
||||
chevron.textContent = '\u25b8';
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
chevron.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
|
||||
row.addEventListener('click', function () {
|
||||
if (isSelectable(kind)) {
|
||||
setSelected(fullPath, kind, row);
|
||||
} else if (kind === 'folder') {
|
||||
if (isOpen) collapse(); else expand();
|
||||
}
|
||||
});
|
||||
|
||||
if (kind === 'folder') {
|
||||
row.addEventListener('dblclick', function (e) {
|
||||
e.preventDefault();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rowEl: row,
|
||||
name: name,
|
||||
kind: kind,
|
||||
childrenEl: function () { return childrenEl; },
|
||||
expand: expand
|
||||
};
|
||||
}
|
||||
|
||||
// loadInto fetches folderPath and populates `target` with rows. Returns
|
||||
// an array of the row objects on success, or [] on failure.
|
||||
function loadInto(folderPath, target) {
|
||||
target.textContent = '';
|
||||
var loading = document.createElement('div');
|
||||
loading.className = 'tree-row is-disabled';
|
||||
loading.textContent = '\u2026';
|
||||
target.appendChild(loading);
|
||||
|
||||
return fetchFolder(folderPath).then(function (resp) {
|
||||
target.textContent = '';
|
||||
var rows = [];
|
||||
(resp.entries || []).forEach(function (e) {
|
||||
if (hideFiles && e.kind !== 'folder') return;
|
||||
var r = buildRow(resp.path, e.name, e.kind);
|
||||
target.appendChild(r.rowEl);
|
||||
rows.push(r);
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'tree-row is-disabled';
|
||||
empty.textContent = '(empty)';
|
||||
target.appendChild(empty);
|
||||
}
|
||||
return rows;
|
||||
}).catch(function () {
|
||||
target.textContent = '';
|
||||
var err = document.createElement('div');
|
||||
err.className = 'tree-row';
|
||||
err.textContent = '(failed — tap to retry)';
|
||||
err.addEventListener('click', function () {
|
||||
loadInto(folderPath, target);
|
||||
});
|
||||
target.appendChild(err);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Root selection row — visible when allowRoot and mode accepts folders.
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var rootRow = document.createElement('div');
|
||||
rootRow.className = 'tree-row';
|
||||
var rootChev = document.createElement('span');
|
||||
rootChev.className = 'tree-chevron is-leaf';
|
||||
rootRow.appendChild(rootChev);
|
||||
var rootLabel = document.createElement('span');
|
||||
rootLabel.className = 'tree-name';
|
||||
rootLabel.textContent = '/';
|
||||
rootRow.appendChild(rootLabel);
|
||||
rootRow.addEventListener('click', function () {
|
||||
setSelected('/', 'folder', rootRow);
|
||||
});
|
||||
treeEl.appendChild(rootRow);
|
||||
}
|
||||
|
||||
var rootChildren = document.createElement('div');
|
||||
treeEl.appendChild(rootChildren);
|
||||
|
||||
// Expand the ancestor chain of initialPath so the user lands in
|
||||
// context. For root, just load root children.
|
||||
var segs = (initialPath || '/').split('/').filter(Boolean);
|
||||
var preselectSegs = preselect
|
||||
? preselect.split('/').filter(Boolean)
|
||||
: null;
|
||||
|
||||
loadInto('/', rootChildren).then(function (rows) {
|
||||
return expandChain(rows, segs, '/');
|
||||
}).then(function () {
|
||||
if (preselectSegs === null) return;
|
||||
if (preselectSegs.length === 0) {
|
||||
// Preselect root itself (if allowed).
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var root = treeEl.querySelector('.tree-row');
|
||||
if (root) setSelected('/', 'folder', root);
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectByPath(preselectSegs);
|
||||
});
|
||||
|
||||
// expandChain walks `segments`, looking up each by name in the current
|
||||
// row list, expanding it, and recursing into its children.
|
||||
function expandChain(rows, segments, basePath) {
|
||||
if (segments.length === 0) return Promise.resolve();
|
||||
var seg = segments[0];
|
||||
var match = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].kind === 'folder' && rows[i].name === seg) {
|
||||
match = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) return Promise.resolve();
|
||||
return match.expand().then(function (childRows) {
|
||||
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
|
||||
});
|
||||
}
|
||||
|
||||
// selectByPath walks the visible tree rows to locate the row matching
|
||||
// `segments` and marks it selected. Assumes its ancestors are already
|
||||
// expanded (expandChain ran first).
|
||||
function selectByPath(segments) {
|
||||
var container = rootChildren;
|
||||
var path = '/';
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var kids = container.children;
|
||||
var found = null;
|
||||
for (var j = 0; j < kids.length; j++) {
|
||||
var row = kids[j];
|
||||
if (!row.classList || !row.classList.contains('tree-row')) continue;
|
||||
var nm = row.querySelector('.tree-name');
|
||||
if (nm && nm.textContent === seg) { found = row; break; }
|
||||
}
|
||||
if (!found) return;
|
||||
path = joinPath(path, seg);
|
||||
if (i === segments.length - 1) {
|
||||
if (isSelectable('folder')) setSelected(path, 'folder', found);
|
||||
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
var next = found.nextSibling;
|
||||
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
|
||||
container = next;
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
;(function () {
|
||||
// This script owns the .tree-scroll child of the aside, not the aside itself.
|
||||
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
|
||||
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 fetchFolder(path, expandTo) {
|
||||
var url = folderHref(path) + "?tree=1&foldersOnly=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
|
||||
var live = false // set once the fetched tree is on screen
|
||||
|
||||
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) {
|
||||
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 row = document.createElement("div")
|
||||
row.className = "tree-row"
|
||||
|
||||
var chevron = document.createElement("span")
|
||||
chevron.className = "tree-chevron"
|
||||
chevron.textContent = "▸"
|
||||
row.appendChild(chevron)
|
||||
|
||||
var link = document.createElement("a")
|
||||
link.className = "tree-name tree-folder"
|
||||
link.textContent = entry.name
|
||||
link.href = folderHref(fullPath)
|
||||
row.appendChild(link)
|
||||
|
||||
if (fullPath === activePath) {
|
||||
row.classList.add("is-active")
|
||||
activeRow = row
|
||||
}
|
||||
|
||||
var preChildren = entry.children ? entry.children : null
|
||||
var childrenEl = null
|
||||
var loaded = false
|
||||
var isOpen = false
|
||||
var isLeaf = !entry.hasChildren
|
||||
if (isLeaf) chevron.classList.add("is-leaf")
|
||||
|
||||
function ensureChildrenEl() {
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement("div")
|
||||
childrenEl.className = "tree-children"
|
||||
}
|
||||
return childrenEl
|
||||
}
|
||||
|
||||
function markLeaf() {
|
||||
isLeaf = true
|
||||
isOpen = false
|
||||
chevron.textContent = "▸"
|
||||
chevron.classList.add("is-leaf")
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl)
|
||||
}
|
||||
}
|
||||
|
||||
function loadChildren() {
|
||||
var el = ensureChildrenEl()
|
||||
el.textContent = ""
|
||||
el.appendChild(disabledRow("…"))
|
||||
fetchFolder(fullPath)
|
||||
.then(function (resp) {
|
||||
el.textContent = ""
|
||||
loaded = true
|
||||
if (!resp.entries || resp.entries.length === 0) {
|
||||
markLeaf()
|
||||
return
|
||||
}
|
||||
renderInto(el, fullPath, resp.entries)
|
||||
})
|
||||
.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 (isOpen || isLeaf) 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 (!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 (isOpen) close()
|
||||
else open()
|
||||
})
|
||||
|
||||
return { row: row, open: open, preExpand: !!preChildren }
|
||||
}
|
||||
|
||||
function render() {
|
||||
// If the layout pre-painted a snapshot of the previous page's tree, keep it
|
||||
// on screen and build the live tree off-DOM, swapping it in once ready —
|
||||
// no "…" placeholder, no scroll jump.
|
||||
var listing = document.createElement("div")
|
||||
var hasSnapshot = container.firstChild !== null
|
||||
if (!hasSnapshot) {
|
||||
container.appendChild(listing)
|
||||
listing.appendChild(disabledRow("…"))
|
||||
}
|
||||
|
||||
fetchFolder("/", activePath === "/" ? "" : activePath)
|
||||
.then(function (resp) {
|
||||
listing.textContent = ""
|
||||
renderInto(listing, "/", resp.entries)
|
||||
if (hasSnapshot) {
|
||||
var top = container.scrollTop
|
||||
container.replaceChildren(listing)
|
||||
container.scrollTop = top
|
||||
}
|
||||
live = true
|
||||
if (activeRow) {
|
||||
try {
|
||||
activeRow.scrollIntoView({ block: "nearest" })
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
live = false
|
||||
container.replaceChildren(listing)
|
||||
listing.textContent = ""
|
||||
var err = document.createElement("div")
|
||||
err.className = "tree-row"
|
||||
err.textContent = "(failed — tap to retry)"
|
||||
err.addEventListener("click", function () {
|
||||
container.textContent = ""
|
||||
render()
|
||||
})
|
||||
listing.appendChild(err)
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshot the tree (markup + scroll) on the way out so the next page's
|
||||
// layout can paint it immediately. Skipped until the live tree has rendered,
|
||||
// so a placeholder or error row never becomes the snapshot. The active
|
||||
// highlight is left out; the next page marks its own row.
|
||||
window.addEventListener("pagehide", function () {
|
||||
if (!live) return
|
||||
if (activeRow) activeRow.classList.remove("is-active")
|
||||
var html = container.innerHTML
|
||||
if (activeRow) activeRow.classList.add("is-active") // bfcache may restore this page
|
||||
try {
|
||||
sessionStorage.setItem("tree-snapshot", JSON.stringify({ html: html, top: container.scrollTop }))
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
function setupMenuItem() {
|
||||
var specials = document.querySelector(".actions-special")
|
||||
if (!specials) return
|
||||
|
||||
var item = document.createElement("button")
|
||||
item.type = "button"
|
||||
item.className = "btn btn-block"
|
||||
item.title = "Folders"
|
||||
item.textContent = "FOLDERS"
|
||||
item.addEventListener("click", function () {
|
||||
if (typeof openOverlay === "function") openOverlay(container)
|
||||
})
|
||||
specials.appendChild(item)
|
||||
}
|
||||
|
||||
render()
|
||||
setupMenuItem()
|
||||
})()
|
||||
@@ -0,0 +1,18 @@
|
||||
{{/* Folder tree rows (tree.go), shared by the sidebar and the tree picker.
|
||||
Expandable folders are native <details>: open/close needs no script, and a
|
||||
folder's children load via htmx the first time it opens, unless the server
|
||||
pre-expanded them. A label inside <summary> selects its radio without
|
||||
toggling the folder; the rest of the row toggles. */}}
|
||||
|
||||
{{define "rows"}}{{range .}}{{if .Expandable}}<details class="tree-node"{{if .Children}} open{{end}}>
|
||||
<summary class="{{template "row-class" .}}"><span class="tree-chevron" aria-hidden="true"></span>{{template "label" .}}</summary>
|
||||
<div class="tree-children"{{if not .Children}} hx-get="{{.ChildrenURL}}" hx-trigger="toggle once from:closest details"{{end}}>{{template "rows" .Children}}</div>
|
||||
</details>
|
||||
{{else}}<div class="{{template "row-class" .}}"><span class="tree-chevron is-leaf" aria-hidden="true"></span>{{template "label" .}}</div>
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
{{define "row-class"}}tree-row{{if .Active}} is-active{{end}}{{if and (not .Link) (not .Selectable)}} is-disabled{{end}}{{end}}
|
||||
|
||||
{{define "label"}}{{if .Link}}<a class="tree-name tree-{{.Kind}}" href="{{.URL}}">{{.Name}}</a>
|
||||
{{- else if .Selectable}}<label class="tree-name"><input type="radio" name="pick" value="{{.Path}}" data-kind="{{.Kind}}"{{if .Checked}} checked{{end}} />{{.Name}}</label>
|
||||
{{- else}}<span class="tree-name">{{.Name}}</span>{{end}}{{end}}
|
||||
Reference in New Issue
Block a user