feat(wiki): hierarchical folder navigation (#79) #534
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user