Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keep file tree view icons consistent with icon theme #33921

Merged
merged 25 commits into from
Apr 6, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions modules/fileicon/material.go
Original file line number Diff line number Diff line change
@@ -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,30 +61,21 @@ 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") {
panic("Invalid SVG icon")
}
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 changes: 52 additions & 0 deletions modules/fileicon/render.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 6 additions & 6 deletions modules/git/error.go
Original file line number Diff line number Diff line change
@@ -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
}

42 changes: 19 additions & 23 deletions modules/git/tree_entry.go
Original file line number Diff line number Diff line change
@@ -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,47 +58,41 @@ 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
}
return target, nil
}

// 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
}
20 changes: 5 additions & 15 deletions modules/git/tree_entry_mode.go
Original file line number Diff line number Diff line change
@@ -17,29 +17,19 @@ 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
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":
9 changes: 0 additions & 9 deletions modules/templates/util_render.go
Original file line number Diff line number Diff line change
@@ -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))
6 changes: 4 additions & 2 deletions routers/web/repo/treelist.go
Original file line number Diff line number Diff line change
@@ -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})
}
12 changes: 12 additions & 0 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion routers/web/repo/view_readme.go
Original file line number Diff line number Diff line change
@@ -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
Loading