diff --git a/assets/companion.js b/assets/companion.js
index e2122a2..0bf29d3 100644
--- a/assets/companion.js
+++ b/assets/companion.js
@@ -118,7 +118,9 @@
if (!e.target.closest) return;
// Match both listing styles: table rows expose the file link inside
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
- var anchor = e.target.closest('.list-item a, a.thumb-tile');
+ // The left tree rail's file anchors (a.tree-file) join in too;
+ // folder anchors there end in "/" and fall through to navigation.
+ var anchor = e.target.closest('.list-item a, a.thumb-tile, aside.tree-sidebar a.tree-file');
if (!anchor) return;
var item = anchor.closest('.list-item');
// Only intercept the primary file link, and only for files (not folders).
diff --git a/assets/layout.html b/assets/layout.html
index 36b6109..62c0885 100644
--- a/assets/layout.html
+++ b/assets/layout.html
@@ -13,6 +13,7 @@
+ {{if not .EditMode}}{{end}}
{{block "headScripts" .}}{{end}}
@@ -29,6 +30,7 @@
{{block "headerActions" .}}{{end}}
+ {{if not .EditMode}}{{end}}
{{block "content" .}}{{end}}
diff --git a/assets/style.css b/assets/style.css
index 140ca82..1b33b4b 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -41,6 +41,9 @@
sidebar/TOC top offsets and anchor scroll-padding. Hardcoded (the
desktop header is a single-row grid); revisit if the header wraps. */
--header-h: 3.25rem;
+
+ /* Width of the persistent left folder-tree rail (desktop). */
+ --tree-width: 15rem;
}
/* Anchor / TOC jumps land below the sticky header instead of behind it.
@@ -89,9 +92,9 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
will silently break. */
.page-wrap {
display: grid;
- grid-template-columns: minmax(0, 1fr) 14rem;
+ grid-template-columns: var(--tree-width) minmax(0, 1fr) 14rem;
gap: var(--space-5);
- max-width: 1280px;
+ max-width: 1440px;
margin: 0 auto;
padding: 0 var(--space-4);
width: 100%;
@@ -602,17 +605,44 @@ aside.sidebar:empty { display: none; }
}
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; }
-.tree-chevron, .tree-marker { text-align: center; flex-shrink: 0; }
+.tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; }
-.tree-marker { width: var(--space-4); color: var(--text-muted); }
-.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.tree-children { padding-left: var(--space-5); }
+.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.tree-children { padding-left: var(--space-2); }
.tree-selected-path {
font-size: var(--font-sm);
padding: var(--space-1) 0;
word-break: break-all;
}
+/* Current-page row in the navigation rail. Mirrors .tree-row.is-selected so the
+ two tree surfaces stay visually consistent. */
+.tree-row.is-active {
+ background: var(--bg-panel-hover);
+ border-left: 3px solid var(--primary);
+ padding-left: calc(var(--space-2) - 3px);
+}
+.tree-row.is-active > .tree-name { color: var(--primary-hover); }
+
+/* === Tree sidebar (persistent left navigation rail) ===
+ Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
+ Desktop: a sticky, scrollable rail parked below the header (mirrors .sidebar
+ offsets). Mobile: an overlay drawer toggled by .fab-tree (see responsive). */
+.tree-sidebar {
+ position: sticky;
+ top: calc(var(--header-h) + var(--space-2));
+ align-self: start;
+ max-height: calc(100vh - var(--header-h) - var(--space-4));
+ overflow-y: auto;
+ padding-top: 1rem;
+ padding-right: var(--space-2);
+ border-right: var(--border-dashed);
+ font-size: var(--font-sm);
+ /* Rows are click targets (navigate / toggle), not prose — suppress the
+ text-selection highlight that double/drag clicks would otherwise leave. */
+ user-select: none;
+}
+aside.tree-sidebar:empty { display: none; }
/* === Movie info box === */
.movie-info { margin: var(--space-3) 0; }
@@ -721,6 +751,32 @@ aside.sidebar:empty { display: none; }
}
.sidebar.is-open { display: flex; }
button.fab { display: inline-flex; }
+ /* Tree rail becomes a left-anchored overlay drawer toggled by .fab-tree.
+ Off-grid (position: fixed) so it never steals horizontal space from
+ on narrow viewports. */
+ .tree-sidebar {
+ position: fixed;
+ top: auto;
+ bottom: 5rem;
+ left: var(--space-4);
+ right: auto;
+ width: calc(100% - 2rem);
+ max-width: 20rem;
+ max-height: calc(100vh - 8rem);
+ padding: var(--space-2);
+ border: var(--border);
+ background: var(--bg);
+ display: none;
+ z-index: 60;
+ }
+ .tree-sidebar.is-open { display: block; }
+ /* Smaller FAB stacked just above the menu FAB (3rem tall at --space-4). */
+ .fab-tree {
+ bottom: calc(var(--space-4) + 3rem + var(--space-2));
+ width: 2.5rem;
+ height: 2.5rem;
+ font-size: 1.1rem;
+ }
}
@media (max-width: 600px) {
diff --git a/assets/tree-picker.js b/assets/tree-picker.js
index 41e6f89..d32b53e 100644
--- a/assets/tree-picker.js
+++ b/assets/tree-picker.js
@@ -85,11 +85,6 @@
}
row.appendChild(chevron);
- var marker = document.createElement('span');
- marker.className = 'tree-marker';
- marker.textContent = kind === 'folder' ? '' : '\u00b7';
- row.appendChild(marker);
-
var label = document.createElement('span');
label.className = 'tree-name';
label.textContent = name;
@@ -199,10 +194,6 @@
var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev);
- var rootMarker = document.createElement('span');
- rootMarker.className = 'tree-marker';
- rootRow.appendChild(rootMarker);
- var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name';
rootLabel.textContent = '/';
rootRow.appendChild(rootLabel);
diff --git a/assets/tree-sidebar.js b/assets/tree-sidebar.js
new file mode 100644
index 0000000..2bccd76
--- /dev/null
+++ b/assets/tree-sidebar.js
@@ -0,0 +1,215 @@
+// Persistent left navigation rail. Renders the wiki folder/file tree, expanded
+// to the current page's ancestor chain, and lets the user navigate by clicking
+// folders (links) or open files locally via the companion (a.tree-file, wired
+// in companion.js). Shares the .tree-* CSS and the ?tree endpoint with
+// tree-picker.js but does not reuse its row builder — that one is modal-select
+// behavior, this one is navigation behavior. Hidden in edit mode (the server
+// omits the container).
+
+(function () {
+ var container = document.querySelector('aside.tree-sidebar');
+ 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 fileHref(p) {
+ return '/' + encodeSegments(p);
+ }
+
+ function fetchFolder(path, expandTo) {
+ var url = folderHref(path) + '?tree=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;
+
+ 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) {
+ if (!entries || entries.length === 0) {
+ containerEl.appendChild(disabledRow('(empty)'));
+ return;
+ }
+ 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 isFolder = entry.kind === 'folder';
+
+ var row = document.createElement('div');
+ row.className = 'tree-row';
+
+ var chevron = document.createElement('span');
+ chevron.className = 'tree-chevron';
+ if (isFolder) chevron.textContent = '▸'; // ▸
+ else chevron.classList.add('is-leaf');
+ row.appendChild(chevron);
+
+
+ var link = document.createElement('a');
+ link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file');
+ 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 childrenEl = null;
+ var loaded = false;
+ var isOpen = false;
+
+ function ensureChildrenEl() {
+ if (!childrenEl) {
+ childrenEl = document.createElement('div');
+ childrenEl.className = 'tree-children';
+ }
+ return childrenEl;
+ }
+
+ function loadChildren() {
+ var el = ensureChildrenEl();
+ el.textContent = '';
+ el.appendChild(disabledRow('…'));
+ fetchFolder(fullPath).then(function (resp) {
+ el.textContent = '';
+ renderInto(el, fullPath, resp.entries);
+ loaded = true;
+ }).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 (!isFolder || isOpen) 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 (!isFolder || !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 (!isFolder) return;
+ if (isOpen) close(); else open();
+ });
+
+ return { row: row, open: open, preExpand: !!preChildren };
+ }
+
+ function render() {
+ container.textContent = '';
+ var listing = document.createElement('div');
+ container.appendChild(listing);
+ listing.appendChild(disabledRow('…'));
+
+ fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) {
+ listing.textContent = '';
+ renderInto(listing, '/', resp.entries);
+ if (activeRow) {
+ try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {}
+ }
+ }).catch(function () {
+ listing.textContent = '';
+ var err = document.createElement('div');
+ err.className = 'tree-row';
+ err.textContent = '(failed — tap to retry)';
+ err.addEventListener('click', function () { render(); });
+ listing.appendChild(err);
+ });
+ }
+
+ // Mobile: a smaller FAB stacked above the menu FAB toggles the rail as an
+ // overlay drawer; selecting any entry closes it (mirrors sidebar-fab.js).
+ function setupFab() {
+ var fab = document.createElement('button');
+ fab.type = 'button';
+ fab.className = 'btn btn-fab fab fab-tree';
+ fab.title = 'Folder tree';
+ fab.setAttribute('aria-label', 'Folder tree');
+ fab.setAttribute('aria-expanded', 'false');
+ fab.innerHTML = '';
+ fab.addEventListener('click', function () {
+ var open = container.classList.toggle('is-open');
+ fab.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ container.addEventListener('click', function (e) {
+ if (e.target.closest('a')) {
+ container.classList.remove('is-open');
+ fab.setAttribute('aria-expanded', 'false');
+ }
+ });
+ document.body.appendChild(fab);
+ }
+
+ render();
+ setupFab();
+})();
diff --git a/tree.go b/tree.go
index 6142dcb..3226522 100644
--- a/tree.go
+++ b/tree.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"os"
+ "path/filepath"
"sort"
"strings"
)
@@ -11,6 +12,9 @@ import (
type treeEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
+ // 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"`
}
type treeResponse struct {
@@ -42,6 +46,14 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
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 != "" {
+ expandTreeChain(fsPath, entries, treePathSegments(expandTo))
+ }
+
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
@@ -83,3 +95,39 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
return append(folders, files...), nil
}
+
+// treePathSegments splits a wiki path into its non-empty segments.
+func treePathSegments(p string) []string {
+ var segs []string
+ for _, s := range strings.Split(p, "/") {
+ if s != "" {
+ segs = append(segs, s)
+ }
+ }
+ 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) {
+ 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)
+ if err != nil {
+ return
+ }
+ entries[i].Children = kids
+ expandTreeChain(childFs, entries[i].Children, segs[1:])
+ return
+ }
+}