Adjust sidebar tree

This commit is contained in:
2026-09-07 16:23:38 +02:00
parent adf1540cf9
commit b14941b3e0
2 changed files with 207 additions and 194 deletions
+141 -126
View File
@@ -1,33 +1,29 @@
(function () { ;(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself. // This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector('aside.tree-sidebar .tree-scroll'); var container = document.querySelector("aside.tree-sidebar .tree-scroll")
if (!container) return; if (!container) return
function joinPath(parent, name) { function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name; if (parent === "/" || parent === "") return "/" + name
return parent.replace(/\/+$/, '') + '/' + name; return parent.replace(/\/+$/, "") + "/" + name
} }
function encodeSegments(p) { function encodeSegments(p) {
return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
} }
function folderHref(p) { function folderHref(p) {
if (p === '/' || p === '') return '/'; if (p === "/" || p === "") return "/"
return '/' + encodeSegments(p) + '/'; return "/" + encodeSegments(p) + "/"
}
function fileHref(p) {
return '/' + encodeSegments(p);
} }
function fetchFolder(path, expandTo) { function fetchFolder(path, expandTo) {
var url = folderHref(path) + '?tree=1'; var url = folderHref(path) + "?tree=1&foldersOnly=1"
if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo); if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return fetch(url, { credentials: "same-origin" }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error("HTTP " + r.status)
return r.json(); return r.json()
}); })
} }
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing // Current page as a canonical, DECODED wiki path ("/" for root, no trailing
@@ -35,166 +31,185 @@
// thus fullPath) are decoded, so we must decode here too or paths containing // 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. // spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () { var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, ''); var p = window.location.pathname.replace(/\/+$/, "")
if (p === '') return '/'; if (p === "") return "/"
try { return decodeURIComponent(p); } catch (e) { return p; } try {
})(); return decodeURIComponent(p)
var activeRow = null; } catch (e) {
return p
}
})()
var activeRow = null
function disabledRow(text) { function disabledRow(text) {
var row = document.createElement('div'); var row = document.createElement("div")
row.className = 'tree-row is-disabled'; row.className = "tree-row is-disabled"
row.textContent = text; row.textContent = text
return row; return row
} }
// renderInto fills containerEl with rows for `entries` (children of // renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded // parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse. // ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) { function renderInto(containerEl, parentPath, entries) {
if (!entries || entries.length === 0) {
containerEl.appendChild(disabledRow('(empty)'));
return;
}
entries.forEach(function (entry) { entries.forEach(function (entry) {
var built = buildRow(parentPath, entry); var built = buildRow(parentPath, entry)
containerEl.appendChild(built.row); containerEl.appendChild(built.row)
if (built.preExpand) built.open(); if (built.preExpand) built.open()
}); })
} }
function buildRow(parentPath, entry) { function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name); var fullPath = joinPath(parentPath, entry.name)
var isFolder = entry.kind === 'folder'; var row = document.createElement("div")
row.className = "tree-row"
var row = document.createElement('div'); var chevron = document.createElement("span")
row.className = 'tree-row'; chevron.className = "tree-chevron"
chevron.textContent = "▸"
row.appendChild(chevron)
var chevron = document.createElement('span'); var link = document.createElement("a")
chevron.className = 'tree-chevron'; link.className = "tree-name tree-folder"
if (isFolder) chevron.textContent = '▸'; // ▸ link.textContent = entry.name
else chevron.classList.add('is-leaf'); link.href = folderHref(fullPath)
row.appendChild(chevron); row.appendChild(link)
if (fullPath === activePath) {
var link = document.createElement('a'); row.classList.add("is-active")
link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file'); activeRow = row
link.textContent = entry.name;
link.href = isFolder ? folderHref(fullPath) : fileHref(fullPath);
row.appendChild(link);
if (isFolder && fullPath === activePath) {
row.classList.add('is-active');
activeRow = row;
} }
var preChildren = (isFolder && entry.children) ? entry.children : null; var preChildren = entry.children ? entry.children : null
var childrenEl = null; var childrenEl = null
var loaded = false; var loaded = false
var isOpen = false; var isOpen = false
var isLeaf = false
function ensureChildrenEl() { function ensureChildrenEl() {
if (!childrenEl) { if (!childrenEl) {
childrenEl = document.createElement('div'); childrenEl = document.createElement("div")
childrenEl.className = 'tree-children'; 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)
} }
return childrenEl;
} }
function loadChildren() { function loadChildren() {
var el = ensureChildrenEl(); var el = ensureChildrenEl()
el.textContent = ''; el.textContent = ""
el.appendChild(disabledRow('…')); el.appendChild(disabledRow("…"))
fetchFolder(fullPath).then(function (resp) { fetchFolder(fullPath)
el.textContent = ''; .then(function (resp) {
renderInto(el, fullPath, resp.entries); el.textContent = ""
loaded = true; loaded = true
}).catch(function () { if (!resp.entries || resp.entries.length === 0) {
el.textContent = ''; markLeaf()
var err = document.createElement('div'); return
err.className = 'tree-row'; }
err.textContent = '(failed — tap to retry)'; renderInto(el, fullPath, resp.entries)
err.addEventListener('click', function (e) { })
e.stopPropagation(); .catch(function () {
loadChildren(); el.textContent = ""
}); var err = document.createElement("div")
el.appendChild(err); err.className = "tree-row"
}); err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function (e) {
e.stopPropagation()
loadChildren()
})
el.appendChild(err)
})
} }
function open() { function open() {
if (!isFolder || isOpen) return; if (isOpen || isLeaf) return
var el = ensureChildrenEl(); var el = ensureChildrenEl()
row.parentNode.insertBefore(el, row.nextSibling); row.parentNode.insertBefore(el, row.nextSibling)
chevron.textContent = '▾'; // ▾ chevron.textContent = "▾" // ▾
isOpen = true; isOpen = true
if (!loaded) { if (!loaded) {
if (preChildren) { if (preChildren) {
renderInto(el, fullPath, preChildren); renderInto(el, fullPath, preChildren)
loaded = true; loaded = true
} else { } else {
loadChildren(); loadChildren()
} }
} }
} }
function close() { function close() {
if (!isFolder || !isOpen) return; if (!isOpen) return
if (childrenEl && childrenEl.parentNode) { if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl); childrenEl.parentNode.removeChild(childrenEl)
} }
chevron.textContent = '▸'; chevron.textContent = "▸"
isOpen = false; isOpen = false
} }
// Clicking the name link navigates (folder) or opens the file; clicking // Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders. // anywhere else on the row toggles expansion for folders.
row.addEventListener('click', function (e) { row.addEventListener("click", function (e) {
if (e.target.closest('a.tree-name')) return; if (e.target.closest("a.tree-name")) return
if (!isFolder) return; if (isOpen) close()
if (isOpen) close(); else open(); else open()
}); })
return { row: row, open: open, preExpand: !!preChildren }; return { row: row, open: open, preExpand: !!preChildren }
} }
function render() { function render() {
container.textContent = ''; container.textContent = ""
var listing = document.createElement('div'); var listing = document.createElement("div")
container.appendChild(listing); container.appendChild(listing)
listing.appendChild(disabledRow('…')); listing.appendChild(disabledRow("…"))
fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) { fetchFolder("/", activePath === "/" ? "" : activePath)
listing.textContent = ''; .then(function (resp) {
renderInto(listing, '/', resp.entries); listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (activeRow) { if (activeRow) {
try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {} try {
activeRow.scrollIntoView({ block: "nearest" })
} catch (e) {}
} }
}).catch(function () { })
listing.textContent = ''; .catch(function () {
var err = document.createElement('div'); listing.textContent = ""
err.className = 'tree-row'; var err = document.createElement("div")
err.textContent = '(failed — tap to retry)'; err.className = "tree-row"
err.addEventListener('click', function () { render(); }); err.textContent = "(failed — tap to retry)"
listing.appendChild(err); err.addEventListener("click", function () {
}); render()
})
listing.appendChild(err)
})
} }
function setupMenuItem() { function setupMenuItem() {
var specials = document.querySelector('.actions-special'); var specials = document.querySelector(".actions-special")
if (!specials) return; if (!specials) return
var item = document.createElement("button"); var item = document.createElement("button")
item.type = "button"; item.type = "button"
item.className = "btn btn-block"; item.className = "btn btn-block"
item.title = "Folders"; item.title = "Folders"
item.textContent = "FOLDERS"; item.textContent = "FOLDERS"
item.addEventListener("click", function () { item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(container); if (typeof openOverlay === "function") openOverlay(container)
}); })
specials.appendChild(item); specials.appendChild(item)
} }
render(); render()
setupMenuItem(); setupMenuItem()
})(); })()
+10 -12
View File
@@ -40,18 +40,16 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
entries, err := listTreeEntries(fsPath) foldersOnly := r.URL.Query().Has("foldersOnly")
entries, err := listTreeEntries(fsPath, foldersOnly)
if err != nil { if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError) http.Error(w, "read failed", http.StatusInternalServerError)
return return
} }
// expandTo asks for a nested listing: each folder along the ancestor chain
// carries its own children, recursively, down to the target. Used by the
// tree sidebar to render the current page's chain in one request. The flat
// picker omits expandTo and is unaffected.
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" { if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo)) expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly)
} }
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries} resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
@@ -70,8 +68,8 @@ func canonicalTreePath(urlPath string) string {
// listTreeEntries returns the immediate children of fsPath, filtering hidden // listTreeEntries returns the immediate children of fsPath, filtering hidden
// entries and index.md. Folders are listed before files; both groups are // entries and index.md. Folders are listed before files; both groups are
// sorted alphabetically. // sorted alphabetically. foldersOnly will not list any individual files at all.
func listTreeEntries(fsPath string) ([]treeEntry, error) { func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
raw, err := os.ReadDir(fsPath) raw, err := os.ReadDir(fsPath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -85,7 +83,7 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
if e.IsDir() { if e.IsDir() {
folders = append(folders, treeEntry{Name: name, Kind: "folder"}) folders = append(folders, treeEntry{Name: name, Kind: "folder"})
} else { } else {
if name == "index.md" { if foldersOnly || name == "index.md" {
continue continue
} }
files = append(files, treeEntry{Name: name, Kind: "file"}) files = append(files, treeEntry{Name: name, Kind: "file"})
@@ -113,7 +111,7 @@ func treePathSegments(p string) []string {
// far as it still exists. Segments only ever match real directory names from // far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the // listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree. // listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string) { func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 { if len(segs) == 0 {
return return
} }
@@ -122,12 +120,12 @@ func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
continue continue
} }
childFs := filepath.Join(fsPath, segs[0]) childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs) kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil { if err != nil {
return return
} }
entries[i].Children = kids entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:]) expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return return
} }
} }