Keep file tree view icons consistent with icon theme (#33921)

Fix #33914

before:
![3000-gogitea-gitea-y4ulxr46c4k ws-us118 gitpod io_test_test
gitea_src_branch_main_
gitmodules](https://github.com/user-attachments/assets/ca50eeff-cc44-4041-b01f-c0c5bdd3b6aa)

after:
![3000-gogitea-gitea-y4ulxr46c4k ws-us118 gitpod io_test_test
gitea_src_branch_main_README
md](https://github.com/user-attachments/assets/3b87fdbd-81d0-4831-8a74-4dbfcd5b6d91)

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Kerwin Bryant 2025-04-07 03:35:08 +08:00 committed by GitHub
parent bcc38eb35f
commit 8c9d2bdee3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 170 additions and 86 deletions

View File

@ -13,7 +13,6 @@ import (
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/options" "code.gitea.io/gitea/modules/options"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
) )
@ -62,13 +61,7 @@ func (m *MaterialIconProvider) loadData() {
log.Debug("Loaded material icon rules and SVG images") log.Debug("Loaded material icon rules and SVG images")
} }
func (m *MaterialIconProvider) renderFileIconSVG(ctx reqctx.RequestContext, name, svg, extraClass string) template.HTML { func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg, extraClass string) template.HTML {
data := ctx.GetData()
renderedSVGs, _ := data["_RenderedSVGs"].(map[string]bool)
if renderedSVGs == nil {
renderedSVGs = make(map[string]bool)
data["_RenderedSVGs"] = renderedSVGs
}
// This part is a bit hacky, but it works really well. It should be safe to do so because all SVG icons are generated by us. // This part is a bit hacky, but it works really well. It should be safe to do so because all SVG icons are generated by us.
// Will try to refactor this in the future. // Will try to refactor this in the future.
if !strings.HasPrefix(svg, "<svg") { if !strings.HasPrefix(svg, "<svg") {
@ -76,16 +69,13 @@ func (m *MaterialIconProvider) renderFileIconSVG(ctx reqctx.RequestContext, name
} }
svgID := "svg-mfi-" + name svgID := "svg-mfi-" + name
svgCommonAttrs := `class="svg git-entry-icon ` + extraClass + `" width="16" height="16" aria-hidden="true"` svgCommonAttrs := `class="svg git-entry-icon ` + extraClass + `" width="16" height="16" aria-hidden="true"`
posOuterBefore := strings.IndexByte(svg, '>') if p.IconSVGs[svgID] == "" {
if renderedSVGs[svgID] && posOuterBefore != -1 { p.IconSVGs[svgID] = template.HTML(`<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:])
return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`)
} }
svg = `<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:] return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`)
renderedSVGs[svgID] = true
return template.HTML(svg)
} }
func (m *MaterialIconProvider) FileIcon(ctx reqctx.RequestContext, entry *git.TreeEntry) template.HTML { func (m *MaterialIconProvider) FileIcon(p *RenderedIconPool, entry *git.TreeEntry) template.HTML {
if m.rules == nil { if m.rules == nil {
return BasicThemeIcon(entry) return BasicThemeIcon(entry)
} }
@ -110,7 +100,7 @@ func (m *MaterialIconProvider) FileIcon(ctx reqctx.RequestContext, entry *git.Tr
case entry.IsSubModule(): case entry.IsSubModule():
extraClass = "octicon-file-submodule" extraClass = "octicon-file-submodule"
} }
return m.renderFileIconSVG(ctx, name, iconSVG, extraClass) return m.renderFileIconSVG(p, name, iconSVG, extraClass)
} }
// TODO: use an interface or wrapper for git.Entry to make the code testable. // TODO: use an interface or wrapper for git.Entry to make the code testable.
return BasicThemeIcon(entry) return BasicThemeIcon(entry)

View File

@ -0,0 +1,52 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package fileicon
import (
"html/template"
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
)
type RenderedIconPool struct {
IconSVGs map[string]template.HTML
}
func NewRenderedIconPool() *RenderedIconPool {
return &RenderedIconPool{
IconSVGs: make(map[string]template.HTML),
}
}
func (p *RenderedIconPool) RenderToHTML() template.HTML {
if len(p.IconSVGs) == 0 {
return ""
}
sb := &strings.Builder{}
sb.WriteString(`<div class=tw-hidden>`)
for _, icon := range p.IconSVGs {
sb.WriteString(string(icon))
}
sb.WriteString(`</div>`)
return template.HTML(sb.String())
}
// TODO: use an interface or struct to replace "*git.TreeEntry", to decouple the fileicon module from git module
func RenderEntryIcon(renderedIconPool *RenderedIconPool, entry *git.TreeEntry) template.HTML {
if setting.UI.FileIconTheme == "material" {
return DefaultMaterialIconProvider().FileIcon(renderedIconPool, entry)
}
return BasicThemeIcon(entry)
}
func RenderEntryIconOpen(renderedIconPool *RenderedIconPool, entry *git.TreeEntry) template.HTML {
// TODO: add "open icon" support
if setting.UI.FileIconTheme == "material" {
return DefaultMaterialIconProvider().FileIcon(renderedIconPool, entry)
}
return BasicThemeIcon(entry)
}

View File

@ -32,19 +32,19 @@ func (err ErrNotExist) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// ErrBadLink entry.FollowLink error // ErrSymlinkUnresolved entry.FollowLink error
type ErrBadLink struct { type ErrSymlinkUnresolved struct {
Name string Name string
Message string Message string
} }
func (err ErrBadLink) Error() string { func (err ErrSymlinkUnresolved) Error() string {
return fmt.Sprintf("%s: %s", err.Name, err.Message) return fmt.Sprintf("%s: %s", err.Name, err.Message)
} }
// IsErrBadLink if some error is ErrBadLink // IsErrSymlinkUnresolved if some error is ErrSymlinkUnresolved
func IsErrBadLink(err error) bool { func IsErrSymlinkUnresolved(err error) bool {
_, ok := err.(ErrBadLink) _, ok := err.(ErrSymlinkUnresolved)
return ok return ok
} }

View File

@ -8,6 +8,8 @@ import (
"io" "io"
"sort" "sort"
"strings" "strings"
"code.gitea.io/gitea/modules/util"
) )
// Type returns the type of the entry (commit, tree, blob) // Type returns the type of the entry (commit, tree, blob)
@ -25,7 +27,7 @@ func (te *TreeEntry) Type() string {
// FollowLink returns the entry pointed to by a symlink // FollowLink returns the entry pointed to by a symlink
func (te *TreeEntry) FollowLink() (*TreeEntry, error) { func (te *TreeEntry) FollowLink() (*TreeEntry, error) {
if !te.IsLink() { if !te.IsLink() {
return nil, ErrBadLink{te.Name(), "not a symlink"} return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
} }
// read the link // read the link
@ -56,13 +58,13 @@ func (te *TreeEntry) FollowLink() (*TreeEntry, error) {
} }
if t == nil { if t == nil {
return nil, ErrBadLink{te.Name(), "points outside of repo"} return nil, ErrSymlinkUnresolved{te.Name(), "points outside of repo"}
} }
target, err := t.GetTreeEntryByPath(lnk) target, err := t.GetTreeEntryByPath(lnk)
if err != nil { if err != nil {
if IsErrNotExist(err) { if IsErrNotExist(err) {
return nil, ErrBadLink{te.Name(), "broken link"} return nil, ErrSymlinkUnresolved{te.Name(), "broken link"}
} }
return nil, err return nil, err
} }
@ -70,33 +72,27 @@ func (te *TreeEntry) FollowLink() (*TreeEntry, error) {
} }
// FollowLinks returns the entry ultimately pointed to by a symlink // FollowLinks returns the entry ultimately pointed to by a symlink
func (te *TreeEntry) FollowLinks() (*TreeEntry, error) { func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) {
if !te.IsLink() { if !te.IsLink() {
return nil, ErrBadLink{te.Name(), "not a symlink"} return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
} }
limit := util.OptionalArg(optLimit, 10)
entry := te entry := te
for i := 0; i < 999; i++ { for i := 0; i < limit; i++ {
if entry.IsLink() { if !entry.IsLink() {
next, err := entry.FollowLink()
if err != nil {
return nil, err
}
if next.ID == entry.ID {
return nil, ErrBadLink{
entry.Name(),
"recursive link",
}
}
entry = next
} else {
break break
} }
next, err := entry.FollowLink()
if err != nil {
return nil, err
}
if next.ID == entry.ID {
return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"}
}
entry = next
} }
if entry.IsLink() { if entry.IsLink() {
return nil, ErrBadLink{ return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"}
te.Name(),
"too many levels of symbolic links",
}
} }
return entry, nil return entry, nil
} }

View File

@ -17,16 +17,12 @@ const (
// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of // EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of
// added the base commit will not have the file in its tree so a mode of 0o000000 is used. // added the base commit will not have the file in its tree so a mode of 0o000000 is used.
EntryModeNoEntry EntryMode = 0o000000 EntryModeNoEntry EntryMode = 0o000000
// EntryModeBlob
EntryModeBlob EntryMode = 0o100644 EntryModeBlob EntryMode = 0o100644
// EntryModeExec EntryModeExec EntryMode = 0o100755
EntryModeExec EntryMode = 0o100755
// EntryModeSymlink
EntryModeSymlink EntryMode = 0o120000 EntryModeSymlink EntryMode = 0o120000
// EntryModeCommit EntryModeCommit EntryMode = 0o160000
EntryModeCommit EntryMode = 0o160000 EntryModeTree EntryMode = 0o040000
// EntryModeTree
EntryModeTree EntryMode = 0o040000
) )
// String converts an EntryMode to a string // String converts an EntryMode to a string
@ -34,12 +30,6 @@ func (e EntryMode) String() string {
return strconv.FormatInt(int64(e), 8) return strconv.FormatInt(int64(e), 8)
} }
// ToEntryMode converts a string to an EntryMode
func ToEntryMode(value string) EntryMode {
v, _ := strconv.ParseInt(value, 8, 32)
return EntryMode(v)
}
func ParseEntryMode(mode string) (EntryMode, error) { func ParseEntryMode(mode string) (EntryMode, error) {
switch mode { switch mode {
case "000000": case "000000":

View File

@ -15,8 +15,6 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
@ -181,13 +179,6 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
textColor, itemColor, itemHTML) textColor, itemColor, itemHTML)
} }
func (ut *RenderUtils) RenderFileIcon(entry *git.TreeEntry) template.HTML {
if setting.UI.FileIconTheme == "material" {
return fileicon.DefaultMaterialIconProvider().FileIcon(ut.ctx, entry)
}
return fileicon.BasicThemeIcon(entry)
}
// RenderEmoji renders html text with emoji post processors // RenderEmoji renders html text with emoji post processors
func (ut *RenderUtils) RenderEmoji(text string) template.HTML { func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text)) renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))

View File

@ -8,6 +8,7 @@ import (
pull_model "code.gitea.io/gitea/models/pull" pull_model "code.gitea.io/gitea/models/pull"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/gitdiff" "code.gitea.io/gitea/services/gitdiff"
@ -87,10 +88,11 @@ func transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[str
} }
func TreeViewNodes(ctx *context.Context) { func TreeViewNodes(ctx *context.Context) {
results, err := files_service.GetTreeViewNodes(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path")) renderedIconPool := fileicon.NewRenderedIconPool()
results, err := files_service.GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path"))
if err != nil { if err != nil {
ctx.ServerError("GetTreeViewNodes", err) ctx.ServerError("GetTreeViewNodes", err)
return return
} }
ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results}) ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results, "renderedIconPool": renderedIconPool.IconSVGs})
} }

View File

@ -29,6 +29,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@ -252,6 +253,16 @@ func LastCommit(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplRepoViewList) ctx.HTML(http.StatusOK, tplRepoViewList)
} }
func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) {
renderedIconPool := fileicon.NewRenderedIconPool()
fileIcons := map[string]template.HTML{}
for _, f := range files {
fileIcons[f.Entry.Name()] = fileicon.RenderEntryIcon(renderedIconPool, f.Entry)
}
ctx.Data["FileIcons"] = fileIcons
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
}
func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries { func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries {
tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
if err != nil { if err != nil {
@ -293,6 +304,7 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
return nil return nil
} }
ctx.Data["Files"] = files ctx.Data["Files"] = files
prepareDirectoryFileIcons(ctx, files)
for _, f := range files { for _, f := range files {
if f.Commit == nil { if f.Commit == nil {
ctx.Data["HasFilesWithoutLatestCommit"] = true ctx.Data["HasFilesWithoutLatestCommit"] = true

View File

@ -69,7 +69,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) {
if entry.IsLink() { if entry.IsLink() {
target, err := entry.FollowLinks() target, err := entry.FollowLinks()
if err != nil && !git.IsErrBadLink(err) { if err != nil && !git.IsErrSymlinkUnresolved(err) {
return "", nil, err return "", nil, err
} else if target != nil && (target.IsExecutable() || target.IsRegular()) { } else if target != nil && (target.IsExecutable() || target.IsRegular()) {
readmeFiles[i] = entry readmeFiles[i] = entry

View File

@ -6,12 +6,14 @@ package files
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/url" "net/url"
"path" "path"
"sort" "sort"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -140,8 +142,13 @@ func entryModeString(entryMode git.EntryMode) string {
} }
type TreeViewNode struct { type TreeViewNode struct {
EntryName string `json:"entryName"` EntryName string `json:"entryName"`
EntryMode string `json:"entryMode"` EntryMode string `json:"entryMode"`
EntryIcon template.HTML `json:"entryIcon"`
EntryIconOpen template.HTML `json:"entryIconOpen,omitempty"`
SymLinkedToMode string `json:"symLinkedToMode,omitempty"` // TODO: for the EntryMode="symlink"
FullPath string `json:"fullPath"` FullPath string `json:"fullPath"`
SubmoduleURL string `json:"submoduleUrl,omitempty"` SubmoduleURL string `json:"submoduleUrl,omitempty"`
Children []*TreeViewNode `json:"children,omitempty"` Children []*TreeViewNode `json:"children,omitempty"`
@ -151,13 +158,28 @@ func (node *TreeViewNode) sortLevel() int {
return util.Iif(node.EntryMode == "tree" || node.EntryMode == "commit", 0, 1) return util.Iif(node.EntryMode == "tree" || node.EntryMode == "commit", 0, 1)
} }
func newTreeViewNodeFromEntry(ctx context.Context, commit *git.Commit, parentDir string, entry *git.TreeEntry) *TreeViewNode { func newTreeViewNodeFromEntry(ctx context.Context, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, parentDir string, entry *git.TreeEntry) *TreeViewNode {
node := &TreeViewNode{ node := &TreeViewNode{
EntryName: entry.Name(), EntryName: entry.Name(),
EntryMode: entryModeString(entry.Mode()), EntryMode: entryModeString(entry.Mode()),
FullPath: path.Join(parentDir, entry.Name()), FullPath: path.Join(parentDir, entry.Name()),
} }
if entry.IsLink() {
// TODO: symlink to a folder or a file, the icon differs
target, err := entry.FollowLink()
if err == nil {
_ = target.IsDir()
// if target.IsDir() { } else { }
}
}
if node.EntryIcon == "" {
node.EntryIcon = fileicon.RenderEntryIcon(renderedIconPool, entry)
// TODO: no open icon support yet
// node.EntryIconOpen = fileicon.RenderEntryIconOpen(renderedIconPool, entry)
}
if node.EntryMode == "commit" { if node.EntryMode == "commit" {
if subModule, err := commit.GetSubModule(node.FullPath); err != nil { if subModule, err := commit.GetSubModule(node.FullPath); err != nil {
log.Error("GetSubModule: %v", err) log.Error("GetSubModule: %v", err)
@ -182,7 +204,7 @@ func sortTreeViewNodes(nodes []*TreeViewNode) {
}) })
} }
func listTreeNodes(ctx context.Context, commit *git.Commit, tree *git.Tree, treePath, subPath string) ([]*TreeViewNode, error) { func listTreeNodes(ctx context.Context, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, tree *git.Tree, treePath, subPath string) ([]*TreeViewNode, error) {
entries, err := tree.ListEntries() entries, err := tree.ListEntries()
if err != nil { if err != nil {
return nil, err return nil, err
@ -191,14 +213,14 @@ func listTreeNodes(ctx context.Context, commit *git.Commit, tree *git.Tree, tree
subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/") subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/")
nodes := make([]*TreeViewNode, 0, len(entries)) nodes := make([]*TreeViewNode, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
node := newTreeViewNodeFromEntry(ctx, commit, treePath, entry) node := newTreeViewNodeFromEntry(ctx, renderedIconPool, commit, treePath, entry)
nodes = append(nodes, node) nodes = append(nodes, node)
if entry.IsDir() && subPathDirName == entry.Name() { if entry.IsDir() && subPathDirName == entry.Name() {
subTreePath := treePath + "/" + node.EntryName subTreePath := treePath + "/" + node.EntryName
if subTreePath[0] == '/' { if subTreePath[0] == '/' {
subTreePath = subTreePath[1:] subTreePath = subTreePath[1:]
} }
subNodes, err := listTreeNodes(ctx, commit, entry.Tree(), subTreePath, subPathRemaining) subNodes, err := listTreeNodes(ctx, renderedIconPool, commit, entry.Tree(), subTreePath, subPathRemaining)
if err != nil { if err != nil {
log.Error("listTreeNodes: %v", err) log.Error("listTreeNodes: %v", err)
} else { } else {
@ -210,10 +232,10 @@ func listTreeNodes(ctx context.Context, commit *git.Commit, tree *git.Tree, tree
return nodes, nil return nodes, nil
} }
func GetTreeViewNodes(ctx context.Context, commit *git.Commit, treePath, subPath string) ([]*TreeViewNode, error) { func GetTreeViewNodes(ctx context.Context, renderedIconPool *fileicon.RenderedIconPool, commit *git.Commit, treePath, subPath string) ([]*TreeViewNode, error) {
entry, err := commit.GetTreeEntryByPath(treePath) entry, err := commit.GetTreeEntryByPath(treePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return listTreeNodes(ctx, commit, entry.Tree(), treePath, subPath) return listTreeNodes(ctx, renderedIconPool, commit, entry.Tree(), treePath, subPath)
} }

View File

@ -4,9 +4,11 @@
package files package files
import ( import (
"html/template"
"testing" "testing"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/fileicon"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
@ -62,40 +64,51 @@ func TestGetTreeViewNodes(t *testing.T) {
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
treeNodes, err := GetTreeViewNodes(ctx, ctx.Repo.Commit, "", "") renderedIconPool := fileicon.NewRenderedIconPool()
mockIconForFile := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
}
mockIconForFolder := func(id string) template.HTML {
return template.HTML(`<svg class="svg git-entry-icon octicon-file-directory-fill" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`)
}
treeNodes, err := GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, "", "")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []*TreeViewNode{ assert.Equal(t, []*TreeViewNode{
{ {
EntryName: "docs", EntryName: "docs",
EntryMode: "tree", EntryMode: "tree",
FullPath: "docs", FullPath: "docs",
EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`),
}, },
}, treeNodes) }, treeNodes)
treeNodes, err = GetTreeViewNodes(ctx, ctx.Repo.Commit, "", "docs/README.md") treeNodes, err = GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, "", "docs/README.md")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []*TreeViewNode{ assert.Equal(t, []*TreeViewNode{
{ {
EntryName: "docs", EntryName: "docs",
EntryMode: "tree", EntryMode: "tree",
FullPath: "docs", FullPath: "docs",
EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`),
Children: []*TreeViewNode{ Children: []*TreeViewNode{
{ {
EntryName: "README.md", EntryName: "README.md",
EntryMode: "blob", EntryMode: "blob",
FullPath: "docs/README.md", FullPath: "docs/README.md",
EntryIcon: mockIconForFile(`svg-mfi-readme`),
}, },
}, },
}, },
}, treeNodes) }, treeNodes)
treeNodes, err = GetTreeViewNodes(ctx, ctx.Repo.Commit, "docs", "README.md") treeNodes, err = GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, "docs", "README.md")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, []*TreeViewNode{ assert.Equal(t, []*TreeViewNode{
{ {
EntryName: "README.md", EntryName: "README.md",
EntryMode: "blob", EntryMode: "blob",
FullPath: "docs/README.md", FullPath: "docs/README.md",
EntryIcon: mockIconForFile(`svg-mfi-readme`),
}, },
}, treeNodes) }, treeNodes)
} }

View File

@ -9,13 +9,14 @@
{{svg "octicon-file-directory-fill"}} .. {{svg "octicon-file-directory-fill"}} ..
</a> </a>
{{end}} {{end}}
{{$.FileIconPoolHTML}}
{{range $item := .Files}} {{range $item := .Files}}
<div class="repo-file-item"> <div class="repo-file-item">
{{$entry := $item.Entry}} {{$entry := $item.Entry}}
{{$commit := $item.Commit}} {{$commit := $item.Commit}}
{{$submoduleFile := $item.SubmoduleFile}} {{$submoduleFile := $item.SubmoduleFile}}
<div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}"> <div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}">
{{ctx.RenderUtils.RenderFileIcon $entry}} {{index $.FileIcons $entry.Name}}
{{if $entry.IsSubModule}} {{if $entry.IsSubModule}}
{{$submoduleLink := $submoduleFile.SubmoduleWebLink ctx}} {{$submoduleLink := $submoduleFile.SubmoduleWebLink ctx}}
{{if $submoduleLink}} {{if $submoduleLink}}

View File

@ -3,6 +3,7 @@ import ViewFileTreeItem from './ViewFileTreeItem.vue';
import {onMounted, ref} from 'vue'; import {onMounted, ref} from 'vue';
import {pathEscapeSegments} from '../utils/url.ts'; import {pathEscapeSegments} from '../utils/url.ts';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import {createElementFromHTML} from '../utils/dom.ts';
const elRoot = ref<HTMLElement | null>(null); const elRoot = ref<HTMLElement | null>(null);
@ -18,6 +19,15 @@ const selectedItem = ref('');
async function loadChildren(treePath: string, subPath: string = '') { async function loadChildren(treePath: string, subPath: string = '') {
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
const json = await response.json(); const json = await response.json();
const poolSvgs = [];
for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) {
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>');
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
return json.fileTreeNodes ?? null; return json.fileTreeNodes ?? null;
} }

View File

@ -5,6 +5,8 @@ import {ref} from 'vue';
type Item = { type Item = {
entryName: string; entryName: string;
entryMode: string; entryMode: string;
entryIcon: string;
entryIconOpen: string;
fullPath: string; fullPath: string;
submoduleUrl?: string; submoduleUrl?: string;
children?: Item[]; children?: Item[];
@ -80,7 +82,8 @@ const doGotoSubModule = () => {
> >
<!-- file --> <!-- file -->
<div class="item-content"> <div class="item-content">
<SvgIcon name="octicon-file"/> <!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="item.entryIcon"/>
<span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
</div> </div>
</div> </div>
@ -92,11 +95,13 @@ const doGotoSubModule = () => {
> >
<!-- directory --> <!-- directory -->
<div class="item-toggle"> <div class="item-toggle">
<!-- FIXME: use a general and global class for this animation -->
<SvgIcon v-if="isLoading" name="octicon-sync" class="job-status-rotate"/> <SvgIcon v-if="isLoading" name="octicon-sync" class="job-status-rotate"/>
<SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/> <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/>
</div> </div>
<div class="item-content"> <div class="item-content">
<SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/> <!-- eslint-disable-next-line vue/no-v-html -->
<span class="text primary" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
<span class="gt-ellipsis">{{ item.entryName }}</span> <span class="gt-ellipsis">{{ item.entryName }}</span>
</div> </div>
</div> </div>