From 6010841ee712a0403740aa261c053df52793ea7c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 15:09:23 -0500 Subject: [PATCH] feat(wiki): hierarchical folder navigation with sidebar tree (#79) Support real subdirectories in wiki instead of escaping / to %2F. When navigating to a folder, tries README.md, Home.md, index.md as index pages. If none found, shows a file/folder listing. Includes: - Stop escaping / in WebPathFromRequest (wiki_path.go) - Folder detection with index file fallback - Auto-generated sidebar folder tree - Breadcrumb navigation for nested paths - Folder listing view when no index page exists - CSS for tree sidebar and folder listing --- options/locale/locale_en-US.json | 1 + routers/web/repo/wiki.go | 171 ++++++++++++++++++++++++++++++- services/wiki/wiki_path.go | 4 +- templates/repo/wiki/view.tmpl | 75 +++++++++++++- web_src/css/repo/wiki.css | 27 +++++ 5 files changed, 274 insertions(+), 4 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f61f9eb038..b027f8eb89 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1992,6 +1992,7 @@ "repo.wiki.page_already_exists": "A wiki page with the same name already exists.", "repo.wiki.reserved_page": "The wiki page name \"%s\" is reserved.", "repo.wiki.pages": "Pages", + "repo.wiki.folder_empty": "This folder is empty.", "repo.wiki.last_updated": "Last updated %s", "repo.wiki.page_name_desc": "Enter a name for this Wiki page. Some special names are: 'Home', '_Sidebar' and '_Footer'.", "repo.wiki.original_git_entry_tooltip": "View original Git file instead of using friendly link.", diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index accfc5b319..d4c526581d 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -77,6 +77,20 @@ type PageMeta struct { UpdatedUnix timeutil.TimeStamp } +// WikiTreeNode represents a node in the wiki folder tree for sidebar navigation. +type WikiTreeNode struct { + Name string + SubURL string + IsDir bool + Children []*WikiTreeNode +} + +// WikiBreadcrumb represents a breadcrumb segment. +type WikiBreadcrumb struct { + Name string + SubURL string +} + // findEntryForFile finds the tree entry for a target filepath. func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) { entry, err := commit.GetTreeEntryByPath(target) @@ -232,10 +246,46 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { isSideBar := pageName == "_Sidebar" isFooter := pageName == "_Footer" + // Build breadcrumbs for the current path + breadcrumbs := buildWikiBreadcrumbs(pageName) + ctx.Data["WikiBreadcrumbs"] = breadcrumbs + + // Build folder tree for sidebar navigation + wikiTree := buildWikiTree(commit) + ctx.Data["WikiTree"] = wikiTree + // lookup filename in wiki - get gitTree entry , real filename entry, pageFilename, noEntry, isRaw := wikiEntryByName(ctx, commit, pageName) if noEntry { - ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") + // Check if path is a directory - try index files or show folder listing + dirEntry, _ := commit.GetTreeEntryByPath(string(pageName)) + if dirEntry != nil && dirEntry.IsDir() { + // Try index files: README.md, Home.md, index.md + for _, indexName := range []string{"README", "Home", "index"} { + indexPath := wiki_service.WebPath(string(pageName) + "/" + indexName) + idxEntry, _, idxNoEntry, _ := wikiEntryByName(ctx, commit, indexPath) + if !idxNoEntry && idxEntry != nil { + // Found index file - render it + pageName = indexPath + entry = idxEntry + _, displayName = wiki_service.WebPathToUserTitle(pageName) + ctx.Data["PageURL"] = wiki_service.WebPathToURLPath(pageName) + ctx.Data["Title"] = displayName + noEntry = false + break + } + } + if noEntry { + // No index file - show folder listing + ctx.Data["IsWikiFolder"] = true + ctx.Data["WikiFolderPath"] = string(pageName) + folderEntries := listWikiFolderEntries(commit, string(pageName)) + ctx.Data["WikiFolderEntries"] = folderEntries + return wikiGitRepo, nil + } + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") + } } if isRaw { ctx.Redirect(ctx.Repo.RepoLink + "/wiki/raw/" + string(pageName)) @@ -752,3 +802,122 @@ func DeleteWikiPagePost(ctx *context.Context) { ctx.JSONRedirect(ctx.Repo.RepoLink + "/wiki/") } + +// buildWikiBreadcrumbs creates breadcrumb segments from a wiki path. +func buildWikiBreadcrumbs(pageName wiki_service.WebPath) []WikiBreadcrumb { + parts := strings.Split(string(pageName), "/") + crumbs := make([]WikiBreadcrumb, 0, len(parts)) + for i, part := range parts { + if part == "" { + continue + } + subURL := strings.Join(parts[:i+1], "/") + crumbs = append(crumbs, WikiBreadcrumb{ + Name: part, + SubURL: subURL, + }) + } + return crumbs +} + +// buildWikiTree builds a hierarchical folder tree from the wiki git repo. +func buildWikiTree(commit *git.Commit) []*WikiTreeNode { + if commit == nil { + return nil + } + entries, err := commit.ListEntries() + if err != nil { + return nil + } + + root := make(map[string]*WikiTreeNode) + var topLevel []*WikiTreeNode + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + node := &WikiTreeNode{ + Name: name, + SubURL: name, + IsDir: true, + } + // List children of this directory + subTree := entry.Tree() + if subTree != nil { + children, _ := subTree.ListEntries() + for _, child := range children { + childName := child.Name() + if child.IsDir() { + node.Children = append(node.Children, &WikiTreeNode{ + Name: childName, + SubURL: name + "/" + childName, + IsDir: true, + }) + } else if strings.HasSuffix(childName, ".md") { + wpName := strings.TrimSuffix(childName, ".md") + if wpName == "_Sidebar" || wpName == "_Footer" { + continue + } + node.Children = append(node.Children, &WikiTreeNode{ + Name: wpName, + SubURL: name + "/" + wpName, + IsDir: false, + }) + } + } + } + root[name] = node + topLevel = append(topLevel, node) + } else if strings.HasSuffix(name, ".md") { + wpName := strings.TrimSuffix(name, ".md") + if wpName == "_Sidebar" || wpName == "_Footer" { + continue + } + node := &WikiTreeNode{ + Name: wpName, + SubURL: wpName, + IsDir: false, + } + topLevel = append(topLevel, node) + } + } + return topLevel +} + +// listWikiFolderEntries lists the pages and subfolders in a wiki directory. +func listWikiFolderEntries(commit *git.Commit, treePath string) []PageMeta { + if commit == nil { + return nil + } + tree, err := commit.SubTree(treePath) + if err != nil { + return nil + } + entries, err := tree.ListEntries() + if err != nil { + return nil + } + + var pages []PageMeta + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + pages = append(pages, PageMeta{ + Name: name + "/", + SubURL: treePath + "/" + name, + GitEntryName: name, + }) + } else if strings.HasSuffix(name, ".md") { + wpName := strings.TrimSuffix(name, ".md") + if wpName == "_Sidebar" || wpName == "_Footer" { + continue + } + pages = append(pages, PageMeta{ + Name: wpName, + SubURL: treePath + "/" + wpName, + GitEntryName: name, + }) + } + } + return pages +} diff --git a/services/wiki/wiki_path.go b/services/wiki/wiki_path.go index c99c419f36..648816cbcd 100644 --- a/services/wiki/wiki_path.go +++ b/services/wiki/wiki_path.go @@ -144,8 +144,8 @@ func WebPathToURLPath(s WebPath) string { func WebPathFromRequest(s string) WebPath { s = util.PathJoinRelX(s) - // The old wiki code's behavior is always using %2F, instead of subdirectory. - s = strings.ReplaceAll(s, "/", "%2F") + // MokoGitea: support real subdirectories for hierarchical wiki navigation. + // Slashes are preserved as path separators, not escaped to %2F. return WebPath(s) } diff --git a/templates/repo/wiki/view.tmpl b/templates/repo/wiki/view.tmpl index 4c7ef364d2..ee1daca102 100644 --- a/templates/repo/wiki/view.tmpl +++ b/templates/repo/wiki/view.tmpl @@ -55,12 +55,51 @@ + {{if .WikiBreadcrumbs}} + {{if gt (len .WikiBreadcrumbs) 1}} +
+ + {{svg "octicon-book" 14}} Wiki + {{range .WikiBreadcrumbs}} + / + {{.Name}} + {{end}} + +
+ {{end}} + {{end}} + {{if .FormatWarning}}

{{.FormatWarning}}

{{end}} + {{if .IsWikiFolder}} +

+ {{svg "octicon-file-directory" 16 "tw-mr-2"}}{{.WikiFolderPath}} +

+
+ {{if .WikiFolderEntries}} +
+ {{range .WikiFolderEntries}} +
+ {{if (StringUtils.HasSuffix .Name "/")}} + {{svg "octicon-file-directory" 16 "tw-mr-1"}} + {{.Name}} + {{else}} + {{svg "octicon-file" 16 "tw-mr-1"}} + {{.Name}} + {{end}} +
+ {{end}} +
+ {{else}} +

This folder is empty.

+ {{end}} +
+ {{end}} +
{{if .WikiSidebarTocHTML}} {{end}} -
+
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{.WikiContentHTML}}
+ {{if .WikiTree}} + + {{end}} + {{if .WikiSidebarHTML}}