Skip to content

Commit 34e5df6

Browse files
authored
Add material icons for file list (go-gitea#33837)
1 parent ae63568 commit 34e5df6

22 files changed

+13993
-73
lines changed

custom/conf/app.example.ini

+3
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,9 @@ LEVEL = Info
12941294
;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css"
12951295
;THEMES =
12961296
;;
1297+
;; The icons for file list (basic/material), this is a temporary option which will be replaced by a user setting in the future.
1298+
;FILE_ICON_THEME = material
1299+
;;
12971300
;; All available reactions users can choose on issues/prs and comments.
12981301
;; Values can be emoji alias (:smile:) or a unicode emoji.
12991302
;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png

modules/base/tool.go

-22
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"strings"
1818
"time"
1919

20-
"code.gitea.io/gitea/modules/git"
2120
"code.gitea.io/gitea/modules/setting"
2221
"code.gitea.io/gitea/modules/util"
2322

@@ -139,24 +138,3 @@ func Int64sToStrings(ints []int64) []string {
139138
}
140139
return strs
141140
}
142-
143-
// EntryIcon returns the octicon name for displaying files/directories
144-
func EntryIcon(entry *git.TreeEntry) string {
145-
switch {
146-
case entry.IsLink():
147-
te, err := entry.FollowLink()
148-
if err != nil {
149-
return "file-symlink-file"
150-
}
151-
if te.IsDir() {
152-
return "file-directory-symlink"
153-
}
154-
return "file-symlink-file"
155-
case entry.IsDir():
156-
return "file-directory-fill"
157-
case entry.IsSubModule():
158-
return "file-submodule"
159-
}
160-
161-
return "file"
162-
}

modules/fileicon/basic.go

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package fileicon
5+
6+
import (
7+
"html/template"
8+
9+
"code.gitea.io/gitea/modules/git"
10+
"code.gitea.io/gitea/modules/svg"
11+
)
12+
13+
func BasicThemeIcon(entry *git.TreeEntry) template.HTML {
14+
svgName := "octicon-file"
15+
switch {
16+
case entry.IsLink():
17+
svgName = "octicon-file-symlink-file"
18+
if te, err := entry.FollowLink(); err == nil && te.IsDir() {
19+
svgName = "octicon-file-directory-symlink"
20+
}
21+
case entry.IsDir():
22+
svgName = "octicon-file-directory-fill"
23+
case entry.IsSubModule():
24+
svgName = "octicon-file-submodule"
25+
}
26+
return svg.RenderHTML(svgName)
27+
}

modules/fileicon/material.go

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package fileicon
5+
6+
import (
7+
"html/template"
8+
"path"
9+
"strings"
10+
"sync"
11+
12+
"code.gitea.io/gitea/modules/git"
13+
"code.gitea.io/gitea/modules/json"
14+
"code.gitea.io/gitea/modules/log"
15+
"code.gitea.io/gitea/modules/options"
16+
"code.gitea.io/gitea/modules/reqctx"
17+
"code.gitea.io/gitea/modules/svg"
18+
)
19+
20+
type materialIconRulesData struct {
21+
IconDefinitions map[string]*struct {
22+
IconPath string `json:"iconPath"`
23+
} `json:"iconDefinitions"`
24+
FileNames map[string]string `json:"fileNames"`
25+
FolderNames map[string]string `json:"folderNames"`
26+
FileExtensions map[string]string `json:"fileExtensions"`
27+
LanguageIDs map[string]string `json:"languageIds"`
28+
}
29+
30+
type MaterialIconProvider struct {
31+
once sync.Once
32+
rules *materialIconRulesData
33+
svgs map[string]string
34+
}
35+
36+
var materialIconProvider MaterialIconProvider
37+
38+
func DefaultMaterialIconProvider() *MaterialIconProvider {
39+
return &materialIconProvider
40+
}
41+
42+
func (m *MaterialIconProvider) loadData() {
43+
buf, err := options.AssetFS().ReadFile("fileicon/material-icon-rules.json")
44+
if err != nil {
45+
log.Error("Failed to read material icon rules: %v", err)
46+
return
47+
}
48+
err = json.Unmarshal(buf, &m.rules)
49+
if err != nil {
50+
log.Error("Failed to unmarshal material icon rules: %v", err)
51+
return
52+
}
53+
54+
buf, err = options.AssetFS().ReadFile("fileicon/material-icon-svgs.json")
55+
if err != nil {
56+
log.Error("Failed to read material icon rules: %v", err)
57+
return
58+
}
59+
err = json.Unmarshal(buf, &m.svgs)
60+
if err != nil {
61+
log.Error("Failed to unmarshal material icon rules: %v", err)
62+
return
63+
}
64+
log.Debug("Loaded material icon rules and SVG images")
65+
}
66+
67+
func (m *MaterialIconProvider) renderFileIconSVG(ctx reqctx.RequestContext, name, svg string) template.HTML {
68+
data := ctx.GetData()
69+
renderedSVGs, _ := data["_RenderedSVGs"].(map[string]bool)
70+
if renderedSVGs == nil {
71+
renderedSVGs = make(map[string]bool)
72+
data["_RenderedSVGs"] = renderedSVGs
73+
}
74+
// 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.
75+
// Will try to refactor this in the future.
76+
if !strings.HasPrefix(svg, "<svg") {
77+
panic("Invalid SVG icon")
78+
}
79+
svgID := "svg-mfi-" + name
80+
svgCommonAttrs := `class="svg fileicon" width="16" height="16" aria-hidden="true"`
81+
posOuterBefore := strings.IndexByte(svg, '>')
82+
if renderedSVGs[svgID] && posOuterBefore != -1 {
83+
return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`)
84+
}
85+
svg = `<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:]
86+
renderedSVGs[svgID] = true
87+
return template.HTML(svg)
88+
}
89+
90+
func (m *MaterialIconProvider) FileIcon(ctx reqctx.RequestContext, entry *git.TreeEntry) template.HTML {
91+
m.once.Do(m.loadData)
92+
93+
if m.rules == nil {
94+
return BasicThemeIcon(entry)
95+
}
96+
97+
if entry.IsLink() {
98+
if te, err := entry.FollowLink(); err == nil && te.IsDir() {
99+
return svg.RenderHTML("material-folder-symlink")
100+
}
101+
return svg.RenderHTML("octicon-file-symlink-file") // TODO: find some better icons for them
102+
}
103+
104+
name := m.findIconName(entry)
105+
if name == "folder" {
106+
// the material icon pack's "folder" icon doesn't look good, so use our built-in one
107+
return svg.RenderHTML("material-folder-generic")
108+
}
109+
if iconSVG, ok := m.svgs[name]; ok && iconSVG != "" {
110+
return m.renderFileIconSVG(ctx, name, iconSVG)
111+
}
112+
return svg.RenderHTML("octicon-file")
113+
}
114+
115+
func (m *MaterialIconProvider) findIconName(entry *git.TreeEntry) string {
116+
if entry.IsSubModule() {
117+
return "folder-git"
118+
}
119+
120+
iconsData := m.rules
121+
fileName := path.Base(entry.Name())
122+
123+
if entry.IsDir() {
124+
if s, ok := iconsData.FolderNames[fileName]; ok {
125+
return s
126+
}
127+
if s, ok := iconsData.FolderNames[strings.ToLower(fileName)]; ok {
128+
return s
129+
}
130+
return "folder"
131+
}
132+
133+
if s, ok := iconsData.FileNames[fileName]; ok {
134+
return s
135+
}
136+
if s, ok := iconsData.FileNames[strings.ToLower(fileName)]; ok {
137+
return s
138+
}
139+
140+
for i := len(fileName) - 1; i >= 0; i-- {
141+
if fileName[i] == '.' {
142+
ext := fileName[i+1:]
143+
if s, ok := iconsData.FileExtensions[ext]; ok {
144+
return s
145+
}
146+
}
147+
}
148+
149+
return "file"
150+
}

modules/reqctx/datastore.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ type RequestContext interface {
9494
}
9595

9696
func FromContext(ctx context.Context) RequestContext {
97+
if rc, ok := ctx.(RequestContext); ok {
98+
return rc
99+
}
97100
// here we must use the current ctx and the underlying store
98101
// the current ctx guarantees that the ctx deadline/cancellation/values are respected
99102
// the underlying store guarantees that the request-specific data is available
@@ -134,6 +137,6 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co
134137

135138
// NewRequestContextForTest creates a new RequestContext for testing purposes
136139
// It doesn't add the context to the process manager, nor do cleanup
137-
func NewRequestContextForTest(parentCtx context.Context) context.Context {
140+
func NewRequestContextForTest(parentCtx context.Context) RequestContext {
138141
return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}}
139142
}

modules/setting/ui.go

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ var UI = struct {
2828
DefaultShowFullName bool
2929
DefaultTheme string
3030
Themes []string
31+
FileIconTheme string
3132
Reactions []string
3233
ReactionsLookup container.Set[string] `ini:"-"`
3334
CustomEmojis []string
@@ -84,6 +85,7 @@ var UI = struct {
8485
ReactionMaxUserNum: 10,
8586
MaxDisplayFileSize: 8388608,
8687
DefaultTheme: `gitea-auto`,
88+
FileIconTheme: `material`,
8789
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
8890
CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
8991
CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},

modules/templates/helper.go

-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ func NewFuncMap() template.FuncMap {
5959
// -----------------------------------------------------------------
6060
// svg / avatar / icon / color
6161
"svg": svg.RenderHTML,
62-
"EntryIcon": base.EntryIcon,
6362
"MigrationIcon": migrationIcon,
6463
"ActionIcon": actionIcon,
6564
"SortArrow": sortArrow,

modules/templates/util_render.go

+12-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package templates
55

66
import (
7-
"context"
87
"encoding/hex"
98
"fmt"
109
"html/template"
@@ -16,20 +15,23 @@ import (
1615

1716
issues_model "code.gitea.io/gitea/models/issues"
1817
"code.gitea.io/gitea/modules/emoji"
18+
"code.gitea.io/gitea/modules/fileicon"
19+
"code.gitea.io/gitea/modules/git"
1920
"code.gitea.io/gitea/modules/htmlutil"
2021
"code.gitea.io/gitea/modules/log"
2122
"code.gitea.io/gitea/modules/markup"
2223
"code.gitea.io/gitea/modules/markup/markdown"
24+
"code.gitea.io/gitea/modules/reqctx"
2325
"code.gitea.io/gitea/modules/setting"
2426
"code.gitea.io/gitea/modules/translation"
2527
"code.gitea.io/gitea/modules/util"
2628
)
2729

2830
type RenderUtils struct {
29-
ctx context.Context
31+
ctx reqctx.RequestContext
3032
}
3133

32-
func NewRenderUtils(ctx context.Context) *RenderUtils {
34+
func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
3335
return &RenderUtils{ctx: ctx}
3436
}
3537

@@ -179,6 +181,13 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
179181
textColor, itemColor, itemHTML)
180182
}
181183

184+
func (ut *RenderUtils) RenderFileIcon(entry *git.TreeEntry) template.HTML {
185+
if setting.UI.FileIconTheme == "material" {
186+
return fileicon.DefaultMaterialIconProvider().FileIcon(ut.ctx, entry)
187+
}
188+
return fileicon.BasicThemeIcon(entry)
189+
}
190+
182191
// RenderEmoji renders html text with emoji post processors
183192
func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
184193
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))

modules/templates/util_render_legacy.go

+9-8
Original file line numberDiff line numberDiff line change
@@ -8,45 +8,46 @@ import (
88
"html/template"
99

1010
issues_model "code.gitea.io/gitea/models/issues"
11+
"code.gitea.io/gitea/modules/reqctx"
1112
"code.gitea.io/gitea/modules/translation"
1213
)
1314

1415
func renderEmojiLegacy(ctx context.Context, text string) template.HTML {
1516
panicIfDevOrTesting()
16-
return NewRenderUtils(ctx).RenderEmoji(text)
17+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderEmoji(text)
1718
}
1819

1920
func renderLabelLegacy(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
2021
panicIfDevOrTesting()
21-
return NewRenderUtils(ctx).RenderLabel(label)
22+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderLabel(label)
2223
}
2324

2425
func renderLabelsLegacy(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
2526
panicIfDevOrTesting()
26-
return NewRenderUtils(ctx).RenderLabels(labels, repoLink, issue)
27+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderLabels(labels, repoLink, issue)
2728
}
2829

2930
func renderMarkdownToHtmlLegacy(ctx context.Context, input string) template.HTML { //nolint:revive
3031
panicIfDevOrTesting()
31-
return NewRenderUtils(ctx).MarkdownToHtml(input)
32+
return NewRenderUtils(reqctx.FromContext(ctx)).MarkdownToHtml(input)
3233
}
3334

3435
func renderCommitMessageLegacy(ctx context.Context, msg string, metas map[string]string) template.HTML {
3536
panicIfDevOrTesting()
36-
return NewRenderUtils(ctx).RenderCommitMessage(msg, metas)
37+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitMessage(msg, metas)
3738
}
3839

3940
func renderCommitMessageLinkSubjectLegacy(ctx context.Context, msg, urlDefault string, metas map[string]string) template.HTML {
4041
panicIfDevOrTesting()
41-
return NewRenderUtils(ctx).RenderCommitMessageLinkSubject(msg, urlDefault, metas)
42+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitMessageLinkSubject(msg, urlDefault, metas)
4243
}
4344

4445
func renderIssueTitleLegacy(ctx context.Context, text string, metas map[string]string) template.HTML {
4546
panicIfDevOrTesting()
46-
return NewRenderUtils(ctx).RenderIssueTitle(text, metas)
47+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderIssueTitle(text, metas)
4748
}
4849

4950
func renderCommitBodyLegacy(ctx context.Context, msg string, metas map[string]string) template.HTML {
5051
panicIfDevOrTesting()
51-
return NewRenderUtils(ctx).RenderCommitBody(msg, metas)
52+
return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitBody(msg, metas)
5253
}

0 commit comments

Comments
 (0)