package main import ( "encoding/json" "net/http" "os" "path/filepath" "sort" "strings" ) 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 { Path string `json:"path"` Entries []treeEntry `json:"entries"` } // 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. 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 } entries, err := listTreeEntries(fsPath) if err != nil { http.Error(w, "read failed", http.StatusInternalServerError) 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) } // 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 "/" } return "/" + strings.Trim(urlPath, "/") } // listTreeEntries returns the immediate children of fsPath, filtering hidden // entries and index.md. Folders are listed before files; both groups are // sorted alphabetically. func listTreeEntries(fsPath string) ([]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"}) } else { if 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 } // 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 } }