diff --git a/modules/fileicon/material.go b/modules/fileicon/material.go
index cbdb962ee3..557f7ca9e4 100644
--- a/modules/fileicon/material.go
+++ b/modules/fileicon/material.go
@@ -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)
diff --git a/modules/fileicon/render.go b/modules/fileicon/render.go
new file mode 100644
index 0000000000..1d014693fd
--- /dev/null
+++ b/modules/fileicon/render.go
@@ -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)
+}
diff --git a/modules/git/error.go b/modules/git/error.go
index 10fb37be07..6c86d1b04d 100644
--- a/modules/git/error.go
+++ b/modules/git/error.go
@@ -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
 }
 
diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go
index 9513121487..a2e1579290 100644
--- a/modules/git/tree_entry.go
+++ b/modules/git/tree_entry.go
@@ -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() {
-			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 {
+	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, ErrSymlinkUnresolved{entry.Name(), "recursive link"}
+		}
+		entry = next
 	}
 	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
 }
diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go
index ec4487549d..1193bec4f1 100644
--- a/modules/git/tree_entry_mode.go
+++ b/modules/git/tree_entry_mode.go
@@ -17,16 +17,12 @@ 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
+
+	EntryModeBlob    EntryMode = 0o100644
+	EntryModeExec    EntryMode = 0o100755
 	EntryModeSymlink EntryMode = 0o120000
-	// EntryModeCommit
-	EntryModeCommit EntryMode = 0o160000
-	// EntryModeTree
-	EntryModeTree EntryMode = 0o040000
+	EntryModeCommit  EntryMode = 0o160000
+	EntryModeTree    EntryMode = 0o040000
 )
 
 // String converts an EntryMode to a string
@@ -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":
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 27316bbfec..ae397d87c9 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -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))
diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go
index ab74741e61..9c5ec8f206 100644
--- a/routers/web/repo/treelist.go
+++ b/routers/web/repo/treelist.go
@@ -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})
 }
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 6ed5801d10..77240f0431 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -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
diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go
index 48befe47f8..606ee7ff79 100644
--- a/routers/web/repo/view_readme.go
+++ b/routers/web/repo/view_readme.go
@@ -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
diff --git a/services/repository/files/tree.go b/services/repository/files/tree.go
index 9142416347..faeb85a046 100644
--- a/services/repository/files/tree.go
+++ b/services/repository/files/tree.go
@@ -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"
@@ -140,8 +142,13 @@ func entryModeString(entryMode git.EntryMode) string {
 }
 
 type TreeViewNode struct {
-	EntryName    string          `json:"entryName"`
-	EntryMode    string          `json:"entryMode"`
+	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)
 }
diff --git a/services/repository/files/tree_test.go b/services/repository/files/tree_test.go
index cbb800da01..2657c49977 100644
--- a/services/repository/files/tree_test.go
+++ b/services/repository/files/tree_test.go
@@ -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)
 }
diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl
index 612a2405e4..572987a986 100644
--- a/templates/repo/view_list.tmpl
+++ b/templates/repo/view_list.tmpl
@@ -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}}
diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue
index 1820c47e7a..c692142792 100644
--- a/web_src/js/components/ViewFileTree.vue
+++ b/web_src/js/components/ViewFileTree.vue
@@ -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;
 }
 
diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue
index 4dffc86a1b..69e26dbc33 100644
--- a/web_src/js/components/ViewFileTreeItem.vue
+++ b/web_src/js/components/ViewFileTreeItem.vue
@@ -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>