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}}
|
||||
@@ -179,3 +179,94 @@ test("a failed move shows the server's reason and keeps the dialog", async ({ pa
|
||||
await expect(modal(page)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/Mover\/$/);
|
||||
});
|
||||
|
||||
function mkpage(rel, title) {
|
||||
mkdirSync(join(wiki, rel), { recursive: true });
|
||||
writeFileSync(join(wiki, rel, "index.md"), `# ${title}\n`);
|
||||
}
|
||||
|
||||
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
|
||||
const treeRequests = [];
|
||||
page.on("request", (r) => {
|
||||
if (r.url().includes("?tree")) treeRequests.push(r.url());
|
||||
});
|
||||
const res = await page.request.get("/Projects/");
|
||||
expect(await res.text()).toMatch(/class="tree-name tree-folder" href="\/Projects\/"/);
|
||||
await page.goto("/Projects/");
|
||||
await expect(sidebar(page).locator(".tree-row.is-active")).toContainText("Projects");
|
||||
expect(treeRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("sidebar folders expand lazily and collapse", async ({ page }) => {
|
||||
mkpage("Tree/One/Deep", "Deep");
|
||||
mkpage("Tree/Two", "Two");
|
||||
await page.goto("/");
|
||||
const treeRow = sidebar(page).locator(".tree-row", { hasText: "Tree" });
|
||||
const chevron = treeRow.locator(".tree-chevron");
|
||||
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
|
||||
await expect(sidebar(page).getByRole("link", { name: "One" })).toHaveCount(0);
|
||||
expect(await glyph()).toBe('"▸"');
|
||||
await chevron.click();
|
||||
await expect(sidebar(page).getByRole("link", { name: "One" })).toBeVisible();
|
||||
expect(await glyph()).toBe('"▾"');
|
||||
// One has a subfolder, Two has none: only One gets a chevron.
|
||||
await expect(sidebar(page).locator("summary.tree-row", { hasText: "One" })).toHaveCount(1);
|
||||
await expect(sidebar(page).locator(".tree-row", { hasText: "Two" }).locator(".tree-chevron")).toHaveClass(/is-leaf/);
|
||||
await treeRow.locator(".tree-chevron").click();
|
||||
await expect(sidebar(page).getByRole("link", { name: "One" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
|
||||
mkpage("Tree/One/Deep", "Deep");
|
||||
mkpage("Mover2", "Mover2");
|
||||
await page.goto("/Mover2/");
|
||||
await pageAction(page, "Move page (M)");
|
||||
const picker = modal(page).locator(".tree-picker");
|
||||
await picker.locator(".tree-row", { hasText: "Tree" }).locator(".tree-chevron").click();
|
||||
await picker.locator(".tree-row", { hasText: "One" }).locator(".tree-chevron").click();
|
||||
await picker.getByText("Deep", { exact: true }).click();
|
||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Tree/One/Deep");
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||
await expect(page).toHaveURL(/\/Tree\/One\/Deep\/Mover2\/$/);
|
||||
});
|
||||
|
||||
test("tree picker can pick the root", async ({ page }) => {
|
||||
mkpage("Tree/Two", "Two");
|
||||
await page.goto("/Tree/Two/");
|
||||
await pageAction(page, "Move page (M)");
|
||||
// The current parent is preselected and its chain expanded.
|
||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Tree");
|
||||
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByPlaceholder("Page name").fill("TwoTop");
|
||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||
await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/TwoTop\/$/);
|
||||
});
|
||||
|
||||
test("editor embed picker selects a file", async ({ page }) => {
|
||||
mkpage("Embeds", "Embeds");
|
||||
writeFileSync(join(wiki, "Embeds", "photo.jpg"), "x");
|
||||
await page.goto("/Embeds/?edit");
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Alt+Shift+G");
|
||||
const picker = modal(page).locator(".tree-picker");
|
||||
// Folder mode rows aren't selectable here; the file is.
|
||||
await picker.getByText("photo.jpg").click();
|
||||
await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Embeds/photo.jpg");
|
||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||
await modal(page).getByRole("button", { name: "INSERT" }).click();
|
||||
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
|
||||
});
|
||||
|
||||
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 400, height: 800 });
|
||||
await page.goto("/Projects/");
|
||||
await expect(sidebar(page)).toBeHidden();
|
||||
await page.getByRole("button", { name: "ACTIONS" }).click();
|
||||
await page.getByTitle("Folders").click();
|
||||
const overlay = page.locator(".overlay");
|
||||
await expect(overlay.getByRole("link", { name: "Projects" })).toBeVisible();
|
||||
await overlay.getByRole("link", { name: "Projects" }).click();
|
||||
await expect(overlay).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -410,6 +410,9 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
SidebarWidget: sidebarWidget,
|
||||
SuppressTOC: suppressTOC,
|
||||
}
|
||||
if !editMode {
|
||||
data.Tree = sidebarTree(h.root, urlPath)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t := pageTmpl
|
||||
|
||||
@@ -61,6 +61,7 @@ type pageData struct {
|
||||
SpecialContent template.HTML
|
||||
SidebarWidget template.HTML
|
||||
SuppressTOC bool
|
||||
Tree template.HTML // sidebar folder tree; empty in edit mode
|
||||
renderTimer
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -47,6 +48,7 @@ type searchPageData struct {
|
||||
PageTotal int
|
||||
FileTotal int
|
||||
IndexBuiltAt time.Time
|
||||
Tree template.HTML // sidebar folder tree
|
||||
renderTimer
|
||||
}
|
||||
|
||||
@@ -131,6 +133,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
PageTotal: len(pages),
|
||||
FileTotal: len(files),
|
||||
IndexBuiltAt: builtAt,
|
||||
Tree: sidebarTree(h.root, "/"),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
|
||||
@@ -1,34 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html"))
|
||||
|
||||
// treeOpts selects what a folder tree renders. The same markup serves the
|
||||
// sidebar (links) and the tree picker (radio inputs named "pick").
|
||||
type treeOpts struct {
|
||||
Files bool // list files, not just folders
|
||||
Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind
|
||||
Active string // wiki path of the current page, highlighted (sidebar)
|
||||
Select string // wiki path of the initially checked row (picker)
|
||||
}
|
||||
|
||||
// query returns the ?tree query that lazy-loads a folder's children with the
|
||||
// same options.
|
||||
func (o treeOpts) query() string {
|
||||
q := "?tree"
|
||||
if o.Files {
|
||||
q += "&files=1"
|
||||
}
|
||||
if o.Pick != "" {
|
||||
q += "&pick=" + o.Pick
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
type treeEntry struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Name string
|
||||
Kind string // "folder" or "file"
|
||||
// HasChildren reports whether a folder has any visible entries (under the
|
||||
// same foldersOnly filter), so the client can hide the expand chevron on
|
||||
// empty folders without a round-trip. Always false for files.
|
||||
HasChildren bool `json:"hasChildren"`
|
||||
// Children is populated only along the expandTo chain (see handleTree);
|
||||
// omitted otherwise so the flat picker listing keeps its original shape.
|
||||
Children []treeEntry `json:"children,omitempty"`
|
||||
// same files filter), so empty folders render without a chevron.
|
||||
HasChildren bool
|
||||
}
|
||||
|
||||
type treeResponse struct {
|
||||
Path string `json:"path"`
|
||||
Entries []treeEntry `json:"entries"`
|
||||
// treeRow is one rendered row. Folders with children are expandable: either
|
||||
// pre-expanded (Children set, along the requested path) or lazy-loaded from
|
||||
// ChildrenURL when first opened.
|
||||
type treeRow struct {
|
||||
Name, Path, Kind string
|
||||
URL string // page URL (sidebar links)
|
||||
Link bool // render the name as a link
|
||||
Selectable bool // render a picker radio
|
||||
Active, Checked bool
|
||||
Expandable bool
|
||||
Children []treeRow
|
||||
ChildrenURL string
|
||||
}
|
||||
|
||||
// handleTree responds with a JSON listing of the immediate children of the
|
||||
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
|
||||
// descended — the client lazy-loads children on expand.
|
||||
// handleTree responds with the rendered rows of the folder at fsPath:
|
||||
//
|
||||
// ?tree sidebar rows (folders only, links)
|
||||
// &files=1 include files
|
||||
// &pick=folder|file|any picker rows selecting that kind
|
||||
// &expandTo=/a/b pre-expand the chain down to /a/b
|
||||
// &select=/a/b check that row
|
||||
// &root=1 lead with a row for "/" itself (picker)
|
||||
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
@@ -44,30 +81,98 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
return
|
||||
}
|
||||
|
||||
foldersOnly := r.URL.Query().Has("foldersOnly")
|
||||
q := r.URL.Query()
|
||||
o := treeOpts{Files: q.Has("files"), Select: q.Get("select")}
|
||||
switch p := q.Get("pick"); p {
|
||||
case "", "folder", "file", "any":
|
||||
o.Pick = p
|
||||
default:
|
||||
http.Error(w, "bad pick mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := listTreeEntries(fsPath, foldersOnly)
|
||||
dir := normalizeMovePath(urlPath)
|
||||
var expand []string
|
||||
if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) &&
|
||||
strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") {
|
||||
expand = segs[len(base):]
|
||||
}
|
||||
rows, err := buildTreeRows(fsPath, dir, expand, o)
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
|
||||
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
|
||||
if dir == "/" && q.Has("root") && o.Pick != "" {
|
||||
root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o)
|
||||
rows = append([]treeRow{root}, rows...)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := treeTmpl.ExecuteTemplate(w, "rows", rows); err != nil {
|
||||
log.Printf("tree template error: %v", err)
|
||||
}
|
||||
|
||||
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// canonicalTreePath returns the URL path in the form used by the picker:
|
||||
// "/" for root, otherwise stripped of any trailing slash.
|
||||
func canonicalTreePath(urlPath string) string {
|
||||
if urlPath == "" || urlPath == "/" {
|
||||
return "/"
|
||||
// sidebarTree renders the navigation tree for the page at urlPath: the root
|
||||
// listing with the chain down to the page expanded and the page highlighted.
|
||||
// The tree is part of the page itself, so the rail never paints empty.
|
||||
func sidebarTree(root, urlPath string) template.HTML {
|
||||
page := normalizeMovePath(urlPath)
|
||||
rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "/" + strings.Trim(urlPath, "/")
|
||||
var buf bytes.Buffer
|
||||
if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil {
|
||||
log.Printf("tree template error: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// buildTreeRows lists the folder fsPath (wiki path dir) and expands the
|
||||
// folder named by expand[0], recursing down the chain. The walk stops at the
|
||||
// deepest segment that still exists. Segments only ever match real directory
|
||||
// names from listTreeEntries, so this cannot traverse outside the tree.
|
||||
func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) {
|
||||
entries, err := listTreeEntries(fsPath, !o.Files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]treeRow, len(entries))
|
||||
for i, e := range entries {
|
||||
p := path.Join(dir, e.Name)
|
||||
rows[i] = newTreeRow(e, p, o)
|
||||
if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren {
|
||||
kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o)
|
||||
if err == nil {
|
||||
rows[i].Children = kids
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func newTreeRow(e treeEntry, p string, o treeOpts) treeRow {
|
||||
row := treeRow{
|
||||
Name: e.Name,
|
||||
Path: p,
|
||||
Kind: e.Kind,
|
||||
URL: pageURL(p),
|
||||
Link: o.Pick == "",
|
||||
Active: o.Pick == "" && p == o.Active,
|
||||
Expandable: e.HasChildren,
|
||||
}
|
||||
if e.Kind == "file" {
|
||||
row.URL = fileURL(strings.TrimPrefix(p, "/"))
|
||||
}
|
||||
if o.Pick != "" {
|
||||
row.Selectable = o.Pick == "any" || o.Pick == e.Kind
|
||||
row.Checked = row.Selectable && p == o.Select
|
||||
}
|
||||
if row.Expandable {
|
||||
row.ChildrenURL = row.URL + o.query()
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// listTreeEntries returns the immediate children of fsPath, filtering hidden
|
||||
@@ -139,28 +244,3 @@ func treePathSegments(p string) []string {
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
// expandTreeChain walks segs, matching each against a folder in entries by
|
||||
// name, loading that folder's children in place, and recursing. The walk stops
|
||||
// at the deepest matching segment, so a stale or deleted path simply expands as
|
||||
// far as it still exists. Segments only ever match real directory names from
|
||||
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
|
||||
// listed tree.
|
||||
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
|
||||
if len(segs) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range entries {
|
||||
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
|
||||
continue
|
||||
}
|
||||
childFs := filepath.Join(fsPath, segs[0])
|
||||
kids, err := listTreeEntries(childFs, foldersOnly)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entries[i].Children = kids
|
||||
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleTreeExpandTo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"Projects/A/Deep", "Other/B"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := &handler{root: root}
|
||||
get := func(urlPath, query string) string {
|
||||
r := httptest.NewRequest("GET", urlPath+"?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTree(w, r, urlPath, filepath.Join(root, filepath.FromSlash(strings.Trim(urlPath, "/"))))
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// A chain below the listed folder is pre-expanded.
|
||||
if body := get("/Projects/", "tree&expandTo=/Projects/A"); !strings.Contains(body, `href="/Projects/A/Deep/"`) {
|
||||
t.Errorf("expandTo below the folder did not expand:\n%s", body)
|
||||
}
|
||||
// Lazy children keep the picker options.
|
||||
if body := get("/", "tree&pick=folder&root=1"); !strings.Contains(body, `hx-get="/Projects/?tree&pick=folder"`) ||
|
||||
!strings.Contains(body, `value="/"`) {
|
||||
t.Errorf("picker rows missing lazy URL or root row:\n%s", body)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.handleTree(w, httptest.NewRequest("GET", "/?tree&pick=bogus", nil), "/", root)
|
||||
if w.Code != 400 {
|
||||
t.Errorf("bad pick mode: got %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user