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>
247 lines
7.2 KiB
Go
247 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"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
|
|
Kind string // "folder" or "file"
|
|
// HasChildren reports whether a folder has any visible entries (under the
|
|
// same files filter), so empty folders render without a chevron.
|
|
HasChildren bool
|
|
}
|
|
|
|
// 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 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 {
|
|
if os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "stat failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !info.IsDir() {
|
|
http.Error(w, "not a folder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
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
|
|
// entries and index.md. Folders are listed before files; both groups are
|
|
// sorted alphabetically. foldersOnly will not list any individual files at all.
|
|
func listTreeEntries(fsPath string, foldersOnly bool) ([]treeEntry, error) {
|
|
raw, err := os.ReadDir(fsPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var folders, files []treeEntry
|
|
for _, e := range raw {
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
if e.IsDir() {
|
|
folders = append(folders, treeEntry{
|
|
Name: name,
|
|
Kind: "folder",
|
|
HasChildren: folderHasChildren(filepath.Join(fsPath, name), foldersOnly),
|
|
})
|
|
} else {
|
|
if foldersOnly || name == "index.md" {
|
|
continue
|
|
}
|
|
files = append(files, treeEntry{Name: name, Kind: "file"})
|
|
}
|
|
}
|
|
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
|
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
|
return append(folders, files...), nil
|
|
}
|
|
|
|
// folderHasChildren reports whether fsPath contains at least one entry that
|
|
// listTreeEntries would list. It reads the directory in small batches and
|
|
// stops at the first match, so large folders stay cheap. Unreadable folders
|
|
// report false.
|
|
func folderHasChildren(fsPath string, foldersOnly bool) bool {
|
|
f, err := os.Open(fsPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
for {
|
|
batch, err := f.ReadDir(32)
|
|
for _, e := range batch {
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
if e.IsDir() || (!foldersOnly && name != "index.md") {
|
|
return true
|
|
}
|
|
}
|
|
if err != nil {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|