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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user