mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Keep file tree view icons consistent with icon theme (#33921)
Fix #33914 before:  after:  --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -13,7 +13,6 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/json" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/options" | ||||
| 	"code.gitea.io/gitea/modules/reqctx" | ||||
| 	"code.gitea.io/gitea/modules/svg" | ||||
| ) | ||||
|  | ||||
| @@ -62,13 +61,7 @@ func (m *MaterialIconProvider) loadData() { | ||||
| 	log.Debug("Loaded material icon rules and SVG images") | ||||
| } | ||||
|  | ||||
| func (m *MaterialIconProvider) renderFileIconSVG(ctx reqctx.RequestContext, 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 | ||||
| 	} | ||||
| func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg, extraClass string) template.HTML { | ||||
| 	// 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. | ||||
| 	if !strings.HasPrefix(svg, "<svg") { | ||||
| @@ -76,16 +69,13 @@ func (m *MaterialIconProvider) renderFileIconSVG(ctx reqctx.RequestContext, name | ||||
| 	} | ||||
| 	svgID := "svg-mfi-" + name | ||||
| 	svgCommonAttrs := `class="svg git-entry-icon ` + extraClass + `" width="16" height="16" aria-hidden="true"` | ||||
| 	posOuterBefore := strings.IndexByte(svg, '>') | ||||
| 	if renderedSVGs[svgID] && posOuterBefore != -1 { | ||||
| 		return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`) | ||||
| 	if p.IconSVGs[svgID] == "" { | ||||
| 		p.IconSVGs[svgID] = template.HTML(`<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:]) | ||||
| 	} | ||||
| 	svg = `<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:] | ||||
| 	renderedSVGs[svgID] = true | ||||
| 	return template.HTML(svg) | ||||
| 	return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></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 { | ||||
| 		return BasicThemeIcon(entry) | ||||
| 	} | ||||
| @@ -110,7 +100,7 @@ func (m *MaterialIconProvider) FileIcon(ctx reqctx.RequestContext, entry *git.Tr | ||||
| 		case entry.IsSubModule(): | ||||
| 			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. | ||||
| 	return BasicThemeIcon(entry) | ||||
|   | ||||
							
								
								
									
										52
									
								
								modules/fileicon/render.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								modules/fileicon/render.go
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
| @@ -32,19 +32,19 @@ func (err ErrNotExist) Unwrap() error { | ||||
| 	return util.ErrNotExist | ||||
| } | ||||
|  | ||||
| // ErrBadLink entry.FollowLink error | ||||
| type ErrBadLink struct { | ||||
| // ErrSymlinkUnresolved entry.FollowLink error | ||||
| type ErrSymlinkUnresolved struct { | ||||
| 	Name    string | ||||
| 	Message string | ||||
| } | ||||
|  | ||||
| func (err ErrBadLink) Error() string { | ||||
| func (err ErrSymlinkUnresolved) Error() string { | ||||
| 	return fmt.Sprintf("%s: %s", err.Name, err.Message) | ||||
| } | ||||
|  | ||||
| // IsErrBadLink if some error is ErrBadLink | ||||
| func IsErrBadLink(err error) bool { | ||||
| 	_, ok := err.(ErrBadLink) | ||||
| // IsErrSymlinkUnresolved if some error is ErrSymlinkUnresolved | ||||
| func IsErrSymlinkUnresolved(err error) bool { | ||||
| 	_, ok := err.(ErrSymlinkUnresolved) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -8,6 +8,8 @@ import ( | ||||
| 	"io" | ||||
| 	"sort" | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
|  | ||||
| // 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 | ||||
| func (te *TreeEntry) FollowLink() (*TreeEntry, error) { | ||||
| 	if !te.IsLink() { | ||||
| 		return nil, ErrBadLink{te.Name(), "not a symlink"} | ||||
| 		return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"} | ||||
| 	} | ||||
|  | ||||
| 	// read the link | ||||
| @@ -56,13 +58,13 @@ func (te *TreeEntry) FollowLink() (*TreeEntry, error) { | ||||
| 	} | ||||
|  | ||||
| 	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) | ||||
| 	if err != nil { | ||||
| 		if IsErrNotExist(err) { | ||||
| 			return nil, ErrBadLink{te.Name(), "broken link"} | ||||
| 			return nil, ErrSymlinkUnresolved{te.Name(), "broken link"} | ||||
| 		} | ||||
| 		return nil, err | ||||
| 	} | ||||
| @@ -70,33 +72,27 @@ func (te *TreeEntry) FollowLink() (*TreeEntry, error) { | ||||
| } | ||||
|  | ||||
| // 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() { | ||||
| 		return nil, ErrBadLink{te.Name(), "not a symlink"} | ||||
| 		return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"} | ||||
| 	} | ||||
| 	limit := util.OptionalArg(optLimit, 10) | ||||
| 	entry := te | ||||
| 	for i := 0; i < 999; i++ { | ||||
| 		if entry.IsLink() { | ||||
| 	for i := 0; i < limit; i++ { | ||||
| 		if !entry.IsLink() { | ||||
| 			break | ||||
| 		} | ||||
| 		next, err := entry.FollowLink() | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		if next.ID == entry.ID { | ||||
| 				return nil, ErrBadLink{ | ||||
| 					entry.Name(), | ||||
| 					"recursive link", | ||||
| 				} | ||||
| 			return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"} | ||||
| 		} | ||||
| 		entry = next | ||||
| 		} else { | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 	if entry.IsLink() { | ||||
| 		return nil, ErrBadLink{ | ||||
| 			te.Name(), | ||||
| 			"too many levels of symbolic links", | ||||
| 		} | ||||
| 		return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"} | ||||
| 	} | ||||
| 	return entry, nil | ||||
| } | ||||
|   | ||||
| @@ -17,15 +17,11 @@ const ( | ||||
| 	// 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. | ||||
| 	EntryModeNoEntry EntryMode = 0o000000 | ||||
| 	// EntryModeBlob | ||||
|  | ||||
| 	EntryModeBlob    EntryMode = 0o100644 | ||||
| 	// EntryModeExec | ||||
| 	EntryModeExec    EntryMode = 0o100755 | ||||
| 	// EntryModeSymlink | ||||
| 	EntryModeSymlink EntryMode = 0o120000 | ||||
| 	// EntryModeCommit | ||||
| 	EntryModeCommit  EntryMode = 0o160000 | ||||
| 	// EntryModeTree | ||||
| 	EntryModeTree    EntryMode = 0o040000 | ||||
| ) | ||||
|  | ||||
| @@ -34,12 +30,6 @@ func (e EntryMode) String() string { | ||||
| 	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) { | ||||
| 	switch mode { | ||||
| 	case "000000": | ||||
|   | ||||
| @@ -15,8 +15,6 @@ import ( | ||||
|  | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	"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/log" | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| @@ -181,13 +179,6 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML { | ||||
| 		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 | ||||
| func (ut *RenderUtils) RenderEmoji(text string) template.HTML { | ||||
| 	renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text)) | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import ( | ||||
|  | ||||
| 	pull_model "code.gitea.io/gitea/models/pull" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/fileicon" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| 	"code.gitea.io/gitea/services/gitdiff" | ||||
| @@ -87,10 +88,11 @@ func transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[str | ||||
| } | ||||
|  | ||||
| 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 { | ||||
| 		ctx.ServerError("GetTreeViewNodes", err) | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results}) | ||||
| 	ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results, "renderedIconPool": renderedIconPool.IconSVGs}) | ||||
| } | ||||
|   | ||||
| @@ -29,6 +29,7 @@ import ( | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/charset" | ||||
| 	"code.gitea.io/gitea/modules/fileicon" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/lfs" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| @@ -252,6 +253,16 @@ func LastCommit(ctx *context.Context) { | ||||
| 	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 { | ||||
| 	tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) | ||||
| 	if err != nil { | ||||
| @@ -293,6 +304,7 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri | ||||
| 		return nil | ||||
| 	} | ||||
| 	ctx.Data["Files"] = files | ||||
| 	prepareDirectoryFileIcons(ctx, files) | ||||
| 	for _, f := range files { | ||||
| 		if f.Commit == nil { | ||||
| 			ctx.Data["HasFilesWithoutLatestCommit"] = true | ||||
|   | ||||
| @@ -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 entry.IsLink() { | ||||
| 					target, err := entry.FollowLinks() | ||||
| 					if err != nil && !git.IsErrBadLink(err) { | ||||
| 					if err != nil && !git.IsErrSymlinkUnresolved(err) { | ||||
| 						return "", nil, err | ||||
| 					} else if target != nil && (target.IsExecutable() || target.IsRegular()) { | ||||
| 						readmeFiles[i] = entry | ||||
|   | ||||
| @@ -6,12 +6,14 @@ package files | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"html/template" | ||||
| 	"net/url" | ||||
| 	"path" | ||||
| 	"sort" | ||||
| 	"strings" | ||||
|  | ||||
| 	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/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| @@ -142,6 +144,11 @@ func entryModeString(entryMode git.EntryMode) string { | ||||
| type TreeViewNode struct { | ||||
| 	EntryName     string        `json:"entryName"` | ||||
| 	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"` | ||||
| 	SubmoduleURL string          `json:"submoduleUrl,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) | ||||
| } | ||||
|  | ||||
| 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{ | ||||
| 		EntryName: entry.Name(), | ||||
| 		EntryMode: entryModeString(entry.Mode()), | ||||
| 		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 subModule, err := commit.GetSubModule(node.FullPath); err != nil { | ||||
| 			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() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -191,14 +213,14 @@ func listTreeNodes(ctx context.Context, commit *git.Commit, tree *git.Tree, tree | ||||
| 	subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/") | ||||
| 	nodes := make([]*TreeViewNode, 0, len(entries)) | ||||
| 	for _, entry := range entries { | ||||
| 		node := newTreeViewNodeFromEntry(ctx, commit, treePath, entry) | ||||
| 		node := newTreeViewNodeFromEntry(ctx, renderedIconPool, commit, treePath, entry) | ||||
| 		nodes = append(nodes, node) | ||||
| 		if entry.IsDir() && subPathDirName == entry.Name() { | ||||
| 			subTreePath := treePath + "/" + node.EntryName | ||||
| 			if subTreePath[0] == '/' { | ||||
| 				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 { | ||||
| 				log.Error("listTreeNodes: %v", err) | ||||
| 			} else { | ||||
| @@ -210,10 +232,10 @@ func listTreeNodes(ctx context.Context, commit *git.Commit, tree *git.Tree, tree | ||||
| 	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) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return listTreeNodes(ctx, commit, entry.Tree(), treePath, subPath) | ||||
| 	return listTreeNodes(ctx, renderedIconPool, commit, entry.Tree(), treePath, subPath) | ||||
| } | ||||
|   | ||||
| @@ -4,9 +4,11 @@ | ||||
| package files | ||||
|  | ||||
| import ( | ||||
| 	"html/template" | ||||
| 	"testing" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/unittest" | ||||
| 	"code.gitea.io/gitea/modules/fileicon" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	api "code.gitea.io/gitea/modules/structs" | ||||
| 	"code.gitea.io/gitea/services/contexttest" | ||||
| @@ -62,40 +64,51 @@ func TestGetTreeViewNodes(t *testing.T) { | ||||
| 	contexttest.LoadGitRepo(t, ctx) | ||||
| 	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.Equal(t, []*TreeViewNode{ | ||||
| 		{ | ||||
| 			EntryName: "docs", | ||||
| 			EntryMode: "tree", | ||||
| 			FullPath:  "docs", | ||||
| 			EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`), | ||||
| 		}, | ||||
| 	}, 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.Equal(t, []*TreeViewNode{ | ||||
| 		{ | ||||
| 			EntryName: "docs", | ||||
| 			EntryMode: "tree", | ||||
| 			FullPath:  "docs", | ||||
| 			EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`), | ||||
| 			Children: []*TreeViewNode{ | ||||
| 				{ | ||||
| 					EntryName: "README.md", | ||||
| 					EntryMode: "blob", | ||||
| 					FullPath:  "docs/README.md", | ||||
| 					EntryIcon: mockIconForFile(`svg-mfi-readme`), | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, 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.Equal(t, []*TreeViewNode{ | ||||
| 		{ | ||||
| 			EntryName: "README.md", | ||||
| 			EntryMode: "blob", | ||||
| 			FullPath:  "docs/README.md", | ||||
| 			EntryIcon: mockIconForFile(`svg-mfi-readme`), | ||||
| 		}, | ||||
| 	}, treeNodes) | ||||
| } | ||||
|   | ||||
| @@ -9,13 +9,14 @@ | ||||
| 		{{svg "octicon-file-directory-fill"}} .. | ||||
| 	</a> | ||||
| 	{{end}} | ||||
| 	{{$.FileIconPoolHTML}} | ||||
| 	{{range $item := .Files}} | ||||
| 		<div class="repo-file-item"> | ||||
| 			{{$entry := $item.Entry}} | ||||
| 			{{$commit := $item.Commit}} | ||||
| 			{{$submoduleFile := $item.SubmoduleFile}} | ||||
| 			<div class="repo-file-cell name muted-links {{if not $commit}}notready{{end}}"> | ||||
| 				{{ctx.RenderUtils.RenderFileIcon $entry}} | ||||
| 				{{index $.FileIcons $entry.Name}} | ||||
| 				{{if $entry.IsSubModule}} | ||||
| 					{{$submoduleLink := $submoduleFile.SubmoduleWebLink ctx}} | ||||
| 					{{if $submoduleLink}} | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import ViewFileTreeItem from './ViewFileTreeItem.vue'; | ||||
| import {onMounted, ref} from 'vue'; | ||||
| import {pathEscapeSegments} from '../utils/url.ts'; | ||||
| import {GET} from '../modules/fetch.ts'; | ||||
| import {createElementFromHTML} from '../utils/dom.ts'; | ||||
|  | ||||
| const elRoot = ref<HTMLElement | null>(null); | ||||
|  | ||||
| @@ -18,6 +19,15 @@ const selectedItem = ref(''); | ||||
| async function loadChildren(treePath: string, subPath: string = '') { | ||||
|   const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); | ||||
|   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; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,8 @@ import {ref} from 'vue'; | ||||
| type Item = { | ||||
|   entryName: string; | ||||
|   entryMode: string; | ||||
|   entryIcon: string; | ||||
|   entryIconOpen: string; | ||||
|   fullPath: string; | ||||
|   submoduleUrl?: string; | ||||
|   children?: Item[]; | ||||
| @@ -80,7 +82,8 @@ const doGotoSubModule = () => { | ||||
|   > | ||||
|     <!-- file --> | ||||
|     <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> | ||||
|     </div> | ||||
|   </div> | ||||
| @@ -92,11 +95,13 @@ const doGotoSubModule = () => { | ||||
|   > | ||||
|     <!-- directory --> | ||||
|     <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-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/> | ||||
|     </div> | ||||
|     <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> | ||||
|     </div> | ||||
|   </div> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Kerwin Bryant
					Kerwin Bryant