feat(wiki): hierarchical folder navigation (#79) #534

Merged
jmiller merged 1 commits from feat/79-wiki-folders into dev 2026-06-06 20:10:30 +00:00
5 changed files with 274 additions and 4 deletions
+1
View File
@@ -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.",
+170 -1
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+74 -1
View File
@@ -55,12 +55,51 @@
</div>
</div>
</div>
{{if .WikiBreadcrumbs}}
{{if gt (len .WikiBreadcrumbs) 1}}
<div class="tw-mb-2">
<span class="breadcrumb">
<a class="section" href="{{.RepoLink}}/wiki/">{{svg "octicon-book" 14}} Wiki</a>
{{range .WikiBreadcrumbs}}
<span class="breadcrumb-divider">/</span>
<a class="section" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
{{end}}
</span>
</div>
{{end}}
{{end}}
{{if .FormatWarning}}
<div class="ui negative message">
<p>{{.FormatWarning}}</p>
</div>
{{end}}
{{if .IsWikiFolder}}
<h4 class="ui top attached header">
{{svg "octicon-file-directory" 16 "tw-mr-2"}}{{.WikiFolderPath}}
</h4>
<div class="ui attached segment">
{{if .WikiFolderEntries}}
<div class="wiki-folder-listing">
{{range .WikiFolderEntries}}
<div class="tw-py-1">
{{if (StringUtils.HasSuffix .Name "/")}}
{{svg "octicon-file-directory" 16 "tw-mr-1"}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{else}}
{{svg "octicon-file" 16 "tw-mr-1"}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="text grey">This folder is empty.</p>
{{end}}
</div>
{{end}}
<div class="wiki-content-parts">
{{if .WikiSidebarTocHTML}}
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
@@ -68,11 +107,45 @@
</div>
{{end}}
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML}}with-sidebar{{end}}">
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{.WikiContentHTML}}
</div>
{{if .WikiTree}}
<div class="render-content markup wiki-content-sidebar wiki-content-tree">
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
<ul class="wiki-tree-list">
{{range .WikiTree}}
<li>
{{if .IsDir}}
{{svg "octicon-file-directory" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{if .Children}}
<ul>
{{range .Children}}
<li>
{{if .IsDir}}
{{svg "octicon-file-directory" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li>
{{end}}
</ul>
{{end}}
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li>
{{end}}
</ul>
</div>
{{end}}
{{if .WikiSidebarHTML}}
<div class="render-content markup wiki-content-sidebar">
{{if and .CanWriteWiki (not .Repository.IsMirror)}}
+27
View File
@@ -50,6 +50,33 @@
border-left-style: dashed;
}
.repository.wiki .wiki-tree-list {
list-style: none;
padding: 0;
margin: 0.5em 0 0 0;
font-size: 0.9em;
}
.repository.wiki .wiki-tree-list ul {
list-style: none;
padding: 0 0 0 1.2em;
margin: 0;
border-left: 1px dashed var(--color-secondary);
}
.repository.wiki .wiki-tree-list li {
padding: 2px 0;
}
.repository.wiki .wiki-tree-list a.active {
font-weight: bold;
color: var(--color-primary);
}
.repository.wiki .wiki-folder-listing {
font-size: 0.95em;
}
@media (max-width: 767.98px) {
.repository.wiki .wiki-content-main.with-sidebar,
.repository.wiki .wiki-content-sidebar {