diff --git a/assets/layout.html b/assets/layout.html
index 29ceb4f..656673f 100644
--- a/assets/layout.html
+++ b/assets/layout.html
@@ -17,8 +17,7 @@
- {{if not .EditMode}}
- {{end}}
+ {{if not .EditMode}}{{end}}
{{block "headScripts" .}}{{end}}
@@ -37,7 +36,9 @@
@@ -46,20 +47,18 @@
{{if not .EditMode}}{{end}}
diff --git a/assets/style.css b/assets/style.css
index 4065483..13fd838 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -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
; 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);
diff --git a/assets/tree-picker.js b/assets/tree-picker.js
index c852ca9..b2b403b 100644
--- a/assets/tree-picker.js
+++ b/assets/tree-picker.js
@@ -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;
}
diff --git a/assets/tree-sidebar.js b/assets/tree-sidebar.js
deleted file mode 100644
index 61fb3a2..0000000
--- a/assets/tree-sidebar.js
+++ /dev/null
@@ -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()
-})()
diff --git a/assets/tree.html b/assets/tree.html
new file mode 100644
index 0000000..2a115d9
--- /dev/null
+++ b/assets/tree.html
@@ -0,0 +1,18 @@
+{{/* Folder tree rows (tree.go), shared by the sidebar and the tree picker.
+Expandable folders are native : 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 selects its radio without
+toggling the folder; the rest of the row toggles. */}}
+
+{{define "rows"}}{{range .}}{{if .Expandable}}
+{{template "label" .}}
+{{template "rows" .Children}}
+
+{{else}}{{template "label" .}}
+{{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}}{{.Name}}
+{{- else if .Selectable}}
+{{- else}}{{.Name}}{{end}}{{end}}
diff --git a/e2e/tests/pages.spec.js b/e2e/tests/pages.spec.js
index a11e5a8..0f50c74 100644
--- a/e2e/tests/pages.spec.js
+++ b/e2e/tests/pages.spec.js
@@ -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);
+});
diff --git a/main.go b/main.go
index 26d3619..3ba6420 100644
--- a/main.go
+++ b/main.go
@@ -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
diff --git a/render.go b/render.go
index 4ebad60..213a882 100644
--- a/render.go
+++ b/render.go
@@ -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
}
diff --git a/search.go b/search.go
index cdc4aa6..29ed48f 100644
--- a/search.go
+++ b/search.go
@@ -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)}
diff --git a/tree.go b/tree.go
index 40d9035..d4910d1 100644
--- a/tree.go
+++ b/tree.go
@@ -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
- }
-}
diff --git a/tree_test.go b/tree_test.go
new file mode 100644
index 0000000..8e80507
--- /dev/null
+++ b/tree_test.go
@@ -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)
+ }
+}