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) + } +}