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}}
+
+ {{end}}
+ {{end}}
+
{{if .FormatWarning}}
{{end}}
+ {{if .IsWikiFolder}}
+
+
+ {{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}}