Render the folder tree on the server

The sidebar tree is part of the page (chain to the current page
expanded, active row marked), so it no longer paints late and the
sessionStorage snapshot is gone; only the rail's scroll position is
kept. Folders are native <details> that lazy-load their children as
HTML via htmx. The tree picker hosts the same server-rendered rows with
radio inputs, so ?tree returns HTML and the JSON listing is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:51:20 +02:00
co-authored by Claude Opus 5.5
parent 353df29ee6
commit 4365fa2567
11 changed files with 357 additions and 567 deletions
+135 -55
View File
@@ -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
}
}