Files
Jonathan Miller bfb4b53da3
Deploy MokoGitea / deploy (push) Successful in 3m31s
feat: add folder-based tree sidebar to org wiki (#680)
Replace flat page list with hierarchical folder tree in org wiki sidebar.
_Sidebar.md takes precedence when present; otherwise auto-generates
collapsible folder menus up to 2 levels deep.
2026-06-21 11:00:18 -05:00

273 lines
8.4 KiB
Go

// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package org
import (
"net/http"
"path"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/gitrepo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/markup/markdown"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
shared_user "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/web/shared/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplOrgWiki templates.TplName = "org/wiki/view"
// OrgWikiPage represents a single page in the org wiki sidebar.
type OrgWikiPage struct {
Name string
SubURL string
}
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
type OrgWikiTreeNode struct {
Name string
SubURL string
IsDir bool
Children []*OrgWikiTreeNode
}
// Wiki renders the org wiki tab.
func Wiki(ctx *context.Context) {
org := ctx.Org.Organization
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["PageIsViewWiki"] = true
ctx.Data["Title"] = org.DisplayName() + " - Wiki"
// Determine which wiki repo to use (public vs member).
viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public"))
viewAsMember := viewAs == "member"
wikiRepo, commit := findOrgWikiCommit(ctx, org.ID, util.Iif(viewAsMember, shared_user.RepoNameWikiPrivate, shared_user.RepoNameWikiPublic))
if wikiRepo == nil && viewAsMember {
// Fall back to public wiki if member wiki doesn't exist.
wikiRepo, commit = findOrgWikiCommit(ctx, org.ID, shared_user.RepoNameWikiPublic)
viewAsMember = false
}
if wikiRepo == nil && !viewAsMember {
// Fall back to member wiki if public wiki doesn't exist.
wikiRepo, commit = findOrgWikiCommit(ctx, org.ID, shared_user.RepoNameWikiPrivate)
viewAsMember = true
}
ctx.Data["IsViewingWikiAsMember"] = viewAsMember
// Check whether both repos exist (for the dropdown toggle).
publicExists := shared_user.OrgWikiRepoExists(ctx, org.ID, shared_user.RepoNameWikiPublic)
privateExists := shared_user.OrgWikiRepoExists(ctx, org.ID, shared_user.RepoNameWikiPrivate)
ctx.Data["ShowWikiViewSelector"] = publicExists && privateExists && ctx.Org.IsMember
if wikiRepo == nil || commit == nil {
ctx.Data["WikiEmpty"] = true
ctx.HTML(http.StatusOK, tplOrgWiki)
return
}
ctx.Data["WikiRepoLink"] = wikiRepo.Link()
// Build folder tree for sidebar navigation.
wikiTree := buildOrgWikiTree(commit)
ctx.Data["WikiTree"] = wikiTree
// Determine which page to render.
pageName := ctx.PathParamRaw("*")
if pageName == "" {
pageName = "Home"
}
ctx.Data["CurrentPage"] = pageName
// Try to find the file: exact match, then with .md extension.
blob := findWikiBlob(commit, pageName)
if blob == nil {
// Page not found — show empty state with page list.
ctx.Data["WikiPageNotFound"] = true
ctx.HTML(http.StatusOK, tplOrgWiki)
return
}
content, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err != nil {
ctx.ServerError("GetBlobContent", err)
return
}
rctx := renderhelper.NewRenderContextRepoFile(ctx, wikiRepo, renderhelper.RepoFileOptions{
CurrentRefPath: path.Join("branch", util.PathEscapeSegments(wikiRepo.DefaultBranch)),
})
renderedContent, err := markdown.RenderString(rctx, content)
if err != nil {
log.Error("Failed to render org wiki page %q: %v", pageName, err)
ctx.ServerError("RenderString", err)
return
}
ctx.Data["WikiContent"] = renderedContent
// Render _Sidebar if it exists.
sidebarBlob := findWikiBlob(commit, "_Sidebar")
if sidebarBlob != nil {
sidebarContent, err := sidebarBlob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err == nil {
rendered, err := markdown.RenderString(rctx, sidebarContent)
if err == nil {
ctx.Data["WikiSidebarHTML"] = rendered
}
}
}
// Render _Footer if it exists.
footerBlob := findWikiBlob(commit, "_Footer")
if footerBlob != nil {
footerContent, err := footerBlob.GetBlobContent(setting.UI.MaxDisplayFileSize)
if err == nil {
rendered, err := markdown.RenderString(rctx, footerContent)
if err == nil {
ctx.Data["WikiFooterHTML"] = rendered
}
}
}
ctx.HTML(http.StatusOK, tplOrgWiki)
}
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
// Shows up to 2 levels deep (folders and their immediate children).
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
if commit == nil {
return nil
}
entries, err := commit.ListEntries()
if err != nil {
return nil
}
var topLevel []*OrgWikiTreeNode
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
node := &OrgWikiTreeNode{
Name: name,
SubURL: name,
IsDir: true,
}
// List children of this directory (1 level deep).
subTree := entry.Tree()
if subTree != nil {
children, _ := subTree.ListEntries()
for _, child := range children {
childName := child.Name()
if child.IsDir() {
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: childName,
SubURL: name + "/" + childName,
IsDir: true,
})
} else if isMarkdownFile(childName) {
displayName := strings.TrimSuffix(childName, path.Ext(childName))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: displayName,
SubURL: name + "/" + displayName,
IsDir: false,
})
}
}
}
topLevel = append(topLevel, node)
} else if isMarkdownFile(name) {
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
topLevel = append(topLevel, &OrgWikiTreeNode{
Name: displayName,
SubURL: displayName,
IsDir: false,
})
}
}
return topLevel
}
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
// Tries fallback repo names (.profile, .github) if the primary doesn't exist.
func findOrgWikiCommit(ctx *context.Context, orgID int64, repoName string) (*repo_model.Repository, *git.Commit) {
namesToTry := shared_user.ProfileRepoFallbacks(repoName)
var dbRepo *repo_model.Repository
var err error
for _, name := range namesToTry {
dbRepo, err = repo_model.GetRepositoryByName(ctx, orgID, name)
if err == nil {
break
}
if !repo_model.IsErrRepoNotExist(err) {
log.Error("findOrgWikiCommit: GetRepositoryByName(%d, %s): %v", orgID, name, err)
return nil, nil
}
}
if dbRepo == nil {
return nil, nil
}
// Open the wiki git repo (.wiki.git sidecar), not the main repo.
wikiGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, dbRepo.WikiStorageRepo())
if err != nil {
// Wiki repo doesn't exist yet — not an error, just no wiki.
return nil, nil
}
branch := dbRepo.DefaultWikiBranch
if branch == "" {
branch = "main"
}
commit, err := wikiGitRepo.GetBranchCommit(branch)
if err != nil {
log.Error("findOrgWikiCommit: GetBranchCommit wiki(%s, %s): %v", dbRepo.FullName(), branch, err)
return nil, nil
}
return dbRepo, commit
}
// findWikiBlob looks up a markdown file in the commit by name.
// Tries exact match first, then appends .md.
func findWikiBlob(commit *git.Commit, name string) *git.Blob {
// Try exact match (e.g., "Home.md").
if blob, _ := commit.GetBlobByPath(name); blob != nil {
return blob
}
// Try with .md extension (e.g., "Home" → "Home.md").
if blob, _ := commit.GetBlobByPath(name + ".md"); blob != nil {
return blob
}
// Try with .markdown extension.
if blob, _ := commit.GetBlobByPath(name + ".markdown"); blob != nil {
return blob
}
return nil
}
// isMarkdownFile returns true if the filename looks like a markdown file.
func isMarkdownFile(name string) bool {
ext := strings.ToLower(path.Ext(name))
return ext == ".md" || ext == ".markdown"
}