Skip to content

Commit 2b76993

Browse files
support the open-icon of folder (#34168)
Co-authored-by: wxiaoguang <[email protected]>
1 parent 44d7d29 commit 2b76993

File tree

20 files changed

+191
-108
lines changed

20 files changed

+191
-108
lines changed

modules/fileicon/basic.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,26 @@ package fileicon
66
import (
77
"html/template"
88

9-
"code.gitea.io/gitea/modules/git"
109
"code.gitea.io/gitea/modules/svg"
10+
"code.gitea.io/gitea/modules/util"
1111
)
1212

13-
func BasicThemeIcon(entry *git.TreeEntry) template.HTML {
13+
func BasicEntryIconName(entry *EntryInfo) string {
1414
svgName := "octicon-file"
1515
switch {
16-
case entry.IsLink():
16+
case entry.EntryMode.IsLink():
1717
svgName = "octicon-file-symlink-file"
18-
if te, err := entry.FollowLink(); err == nil && te.IsDir() {
18+
if entry.SymlinkToMode.IsDir() {
1919
svgName = "octicon-file-directory-symlink"
2020
}
21-
case entry.IsDir():
22-
svgName = "octicon-file-directory-fill"
23-
case entry.IsSubModule():
21+
case entry.EntryMode.IsDir():
22+
svgName = util.Iif(entry.IsOpen, "octicon-file-directory-open-fill", "octicon-file-directory-fill")
23+
case entry.EntryMode.IsSubModule():
2424
svgName = "octicon-file-submodule"
2525
}
26-
return svg.RenderHTML(svgName)
26+
return svgName
27+
}
28+
29+
func BasicEntryIconHTML(entry *EntryInfo) template.HTML {
30+
return svg.RenderHTML(BasicEntryIconName(entry))
2731
}

modules/fileicon/entry.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package fileicon
5+
6+
import "code.gitea.io/gitea/modules/git"
7+
8+
type EntryInfo struct {
9+
FullName string
10+
EntryMode git.EntryMode
11+
SymlinkToMode git.EntryMode
12+
IsOpen bool
13+
}
14+
15+
func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo {
16+
ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
17+
if gitEntry.IsLink() {
18+
if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() {
19+
ret.SymlinkToMode = te.Mode()
20+
}
21+
}
22+
return ret
23+
}
24+
25+
func EntryInfoFolder() *EntryInfo {
26+
return &EntryInfo{EntryMode: git.EntryModeTree}
27+
}
28+
29+
func EntryInfoFolderOpen() *EntryInfo {
30+
return &EntryInfo{EntryMode: git.EntryModeTree, IsOpen: true}
31+
}

modules/fileicon/material.go

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import (
99
"strings"
1010
"sync"
1111

12-
"code.gitea.io/gitea/modules/git"
1312
"code.gitea.io/gitea/modules/json"
1413
"code.gitea.io/gitea/modules/log"
1514
"code.gitea.io/gitea/modules/options"
15+
"code.gitea.io/gitea/modules/setting"
1616
"code.gitea.io/gitea/modules/svg"
17+
"code.gitea.io/gitea/modules/util"
1718
)
1819

1920
type materialIconRulesData struct {
@@ -69,41 +70,51 @@ func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg,
6970
}
7071
svgID := "svg-mfi-" + name
7172
svgCommonAttrs := `class="svg git-entry-icon ` + extraClass + `" width="16" height="16" aria-hidden="true"`
73+
svgHTML := template.HTML(`<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:])
74+
if p == nil {
75+
return svgHTML
76+
}
7277
if p.IconSVGs[svgID] == "" {
73-
p.IconSVGs[svgID] = template.HTML(`<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:])
78+
p.IconSVGs[svgID] = svgHTML
7479
}
7580
return template.HTML(`<svg ` + svgCommonAttrs + `><use xlink:href="#` + svgID + `"></use></svg>`)
7681
}
7782

78-
func (m *MaterialIconProvider) FileIcon(p *RenderedIconPool, entry *git.TreeEntry) template.HTML {
83+
func (m *MaterialIconProvider) EntryIconHTML(p *RenderedIconPool, entry *EntryInfo) template.HTML {
7984
if m.rules == nil {
80-
return BasicThemeIcon(entry)
85+
return BasicEntryIconHTML(entry)
8186
}
8287

83-
if entry.IsLink() {
84-
if te, err := entry.FollowLink(); err == nil && te.IsDir() {
88+
if entry.EntryMode.IsLink() {
89+
if entry.SymlinkToMode.IsDir() {
8590
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work
8691
return svg.RenderHTML("material-folder-symlink", 16, "octicon-file-directory-symlink")
8792
}
8893
return svg.RenderHTML("octicon-file-symlink-file") // TODO: find some better icons for them
8994
}
9095

91-
name := m.findIconNameByGit(entry)
92-
// the material icon pack's "folder" icon doesn't look good, so use our built-in one
93-
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work
94-
if iconSVG, ok := m.svgs[name]; ok && name != "folder" && iconSVG != "" {
95-
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work
96-
extraClass := "octicon-file"
97-
switch {
98-
case entry.IsDir():
99-
extraClass = "octicon-file-directory-fill"
100-
case entry.IsSubModule():
101-
extraClass = "octicon-file-submodule"
96+
name := m.FindIconName(entry)
97+
iconSVG := m.svgs[name]
98+
if iconSVG == "" {
99+
name = "file"
100+
if entry.EntryMode.IsDir() {
101+
name = util.Iif(entry.IsOpen, "folder-open", "folder")
102+
}
103+
iconSVG = m.svgs[name]
104+
if iconSVG == "" {
105+
setting.PanicInDevOrTesting("missing file icon for %s", name)
102106
}
103-
return m.renderFileIconSVG(p, name, iconSVG, extraClass)
104107
}
105-
// TODO: use an interface or wrapper for git.Entry to make the code testable.
106-
return BasicThemeIcon(entry)
108+
109+
// keep the old "octicon-xxx" class name to make some "theme plugin selector" could still work
110+
extraClass := "octicon-file"
111+
switch {
112+
case entry.EntryMode.IsDir():
113+
extraClass = BasicEntryIconName(entry)
114+
case entry.EntryMode.IsSubModule():
115+
extraClass = "octicon-file-submodule"
116+
}
117+
return m.renderFileIconSVG(p, name, iconSVG, extraClass)
107118
}
108119

109120
func (m *MaterialIconProvider) findIconNameWithLangID(s string) string {
@@ -118,13 +129,17 @@ func (m *MaterialIconProvider) findIconNameWithLangID(s string) string {
118129
return ""
119130
}
120131

121-
func (m *MaterialIconProvider) FindIconName(name string, isDir bool) string {
122-
fileNameLower := strings.ToLower(path.Base(name))
123-
if isDir {
132+
func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string {
133+
if entry.EntryMode.IsSubModule() {
134+
return "folder-git"
135+
}
136+
137+
fileNameLower := strings.ToLower(path.Base(entry.FullName))
138+
if entry.EntryMode.IsDir() {
124139
if s, ok := m.rules.FolderNames[fileNameLower]; ok {
125140
return s
126141
}
127-
return "folder"
142+
return util.Iif(entry.IsOpen, "folder-open", "folder")
128143
}
129144

130145
if s, ok := m.rules.FileNames[fileNameLower]; ok {
@@ -146,10 +161,3 @@ func (m *MaterialIconProvider) FindIconName(name string, isDir bool) string {
146161

147162
return "file"
148163
}
149-
150-
func (m *MaterialIconProvider) findIconNameByGit(entry *git.TreeEntry) string {
151-
if entry.IsSubModule() {
152-
return "folder-git"
153-
}
154-
return m.FindIconName(entry.Name(), entry.IsDir())
155-
}

modules/fileicon/material_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"code.gitea.io/gitea/models/unittest"
1010
"code.gitea.io/gitea/modules/fileicon"
11+
"code.gitea.io/gitea/modules/git"
1112

1213
"github.com/stretchr/testify/assert"
1314
)
@@ -19,8 +20,8 @@ func TestMain(m *testing.M) {
1920
func TestFindIconName(t *testing.T) {
2021
unittest.PrepareTestEnv(t)
2122
p := fileicon.DefaultMaterialIconProvider()
22-
assert.Equal(t, "php", p.FindIconName("foo.php", false))
23-
assert.Equal(t, "php", p.FindIconName("foo.PHP", false))
24-
assert.Equal(t, "javascript", p.FindIconName("foo.js", false))
25-
assert.Equal(t, "visualstudio", p.FindIconName("foo.vba", false))
23+
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.php", EntryMode: git.EntryModeBlob}))
24+
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.PHP", EntryMode: git.EntryModeBlob}))
25+
assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.js", EntryMode: git.EntryModeBlob}))
26+
assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.vba", EntryMode: git.EntryModeBlob}))
2627
}

modules/fileicon/render.go

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"html/template"
88
"strings"
99

10-
"code.gitea.io/gitea/modules/git"
1110
"code.gitea.io/gitea/modules/setting"
1211
)
1312

@@ -34,19 +33,9 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML {
3433
return template.HTML(sb.String())
3534
}
3635

37-
// TODO: use an interface or struct to replace "*git.TreeEntry", to decouple the fileicon module from git module
38-
39-
func RenderEntryIcon(renderedIconPool *RenderedIconPool, entry *git.TreeEntry) template.HTML {
40-
if setting.UI.FileIconTheme == "material" {
41-
return DefaultMaterialIconProvider().FileIcon(renderedIconPool, entry)
42-
}
43-
return BasicThemeIcon(entry)
44-
}
45-
46-
func RenderEntryIconOpen(renderedIconPool *RenderedIconPool, entry *git.TreeEntry) template.HTML {
47-
// TODO: add "open icon" support
36+
func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML {
4837
if setting.UI.FileIconTheme == "material" {
49-
return DefaultMaterialIconProvider().FileIcon(renderedIconPool, entry)
38+
return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry)
5039
}
51-
return BasicThemeIcon(entry)
40+
return BasicEntryIconHTML(entry)
5241
}

modules/git/tree_entry_mode.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,31 @@ func (e EntryMode) String() string {
3030
return strconv.FormatInt(int64(e), 8)
3131
}
3232

33+
// IsSubModule if the entry is a sub module
34+
func (e EntryMode) IsSubModule() bool {
35+
return e == EntryModeCommit
36+
}
37+
38+
// IsDir if the entry is a sub dir
39+
func (e EntryMode) IsDir() bool {
40+
return e == EntryModeTree
41+
}
42+
43+
// IsLink if the entry is a symlink
44+
func (e EntryMode) IsLink() bool {
45+
return e == EntryModeSymlink
46+
}
47+
48+
// IsRegular if the entry is a regular file
49+
func (e EntryMode) IsRegular() bool {
50+
return e == EntryModeBlob
51+
}
52+
53+
// IsExecutable if the entry is an executable file (not necessarily binary)
54+
func (e EntryMode) IsExecutable() bool {
55+
return e == EntryModeExec
56+
}
57+
3358
func ParseEntryMode(mode string) (EntryMode, error) {
3459
switch mode {
3560
case "000000":

modules/git/tree_entry_nogogit.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,27 +59,27 @@ func (te *TreeEntry) Size() int64 {
5959

6060
// IsSubModule if the entry is a sub module
6161
func (te *TreeEntry) IsSubModule() bool {
62-
return te.entryMode == EntryModeCommit
62+
return te.entryMode.IsSubModule()
6363
}
6464

6565
// IsDir if the entry is a sub dir
6666
func (te *TreeEntry) IsDir() bool {
67-
return te.entryMode == EntryModeTree
67+
return te.entryMode.IsDir()
6868
}
6969

7070
// IsLink if the entry is a symlink
7171
func (te *TreeEntry) IsLink() bool {
72-
return te.entryMode == EntryModeSymlink
72+
return te.entryMode.IsLink()
7373
}
7474

7575
// IsRegular if the entry is a regular file
7676
func (te *TreeEntry) IsRegular() bool {
77-
return te.entryMode == EntryModeBlob
77+
return te.entryMode.IsRegular()
7878
}
7979

8080
// IsExecutable if the entry is an executable file (not necessarily binary)
8181
func (te *TreeEntry) IsExecutable() bool {
82-
return te.entryMode == EntryModeExec
82+
return te.entryMode.IsExecutable()
8383
}
8484

8585
// Blob returns the blob object the entry

routers/web/repo/commit.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
user_model "code.gitea.io/gitea/models/user"
2222
"code.gitea.io/gitea/modules/base"
2323
"code.gitea.io/gitea/modules/charset"
24+
"code.gitea.io/gitea/modules/fileicon"
2425
"code.gitea.io/gitea/modules/git"
2526
"code.gitea.io/gitea/modules/gitrepo"
2627
"code.gitea.io/gitea/modules/log"
@@ -369,7 +370,11 @@ func Diff(ctx *context.Context) {
369370
return
370371
}
371372

372-
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil)
373+
renderedIconPool := fileicon.NewRenderedIconPool()
374+
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
375+
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
376+
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
377+
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
373378
}
374379

375380
statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll)

routers/web/repo/compare.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"code.gitea.io/gitea/modules/base"
2727
"code.gitea.io/gitea/modules/charset"
2828
csv_module "code.gitea.io/gitea/modules/csv"
29+
"code.gitea.io/gitea/modules/fileicon"
2930
"code.gitea.io/gitea/modules/git"
3031
"code.gitea.io/gitea/modules/gitrepo"
3132
"code.gitea.io/gitea/modules/log"
@@ -639,7 +640,11 @@ func PrepareCompareDiff(
639640
return false
640641
}
641642

642-
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil)
643+
renderedIconPool := fileicon.NewRenderedIconPool()
644+
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil)
645+
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
646+
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
647+
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
643648
}
644649

645650
headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)

routers/web/repo/pull.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"code.gitea.io/gitea/models/unit"
2525
user_model "code.gitea.io/gitea/models/user"
2626
"code.gitea.io/gitea/modules/emoji"
27+
"code.gitea.io/gitea/modules/fileicon"
2728
"code.gitea.io/gitea/modules/git"
2829
"code.gitea.io/gitea/modules/gitrepo"
2930
issue_template "code.gitea.io/gitea/modules/issue/template"
@@ -823,7 +824,12 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
823824
if reviewState != nil {
824825
filesViewedState = reviewState.UpdatedFiles
825826
}
826-
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, filesViewedState)
827+
828+
renderedIconPool := fileicon.NewRenderedIconPool()
829+
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, filesViewedState)
830+
ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
831+
ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen())
832+
ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML()
827833
}
828834

829835
ctx.Data["Diff"] = diff

routers/web/repo/treelist.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package repo
55

66
import (
7+
"html/template"
78
"net/http"
89
"strings"
910

@@ -67,7 +68,7 @@ type WebDiffFileItem struct {
6768
EntryMode string
6869
IsViewed bool
6970
Children []*WebDiffFileItem
70-
// TODO: add icon support in the future
71+
FileIcon template.HTML
7172
}
7273

7374
// WebDiffFileTree is used by frontend, check the field names in frontend before changing
@@ -77,7 +78,7 @@ type WebDiffFileTree struct {
7778

7879
// transformDiffTreeForWeb transforms a gitdiff.DiffTree into a WebDiffFileTree for Web UI rendering
7980
// it also takes a map of file names to their viewed state, which is used to mark files as viewed
80-
func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) {
81+
func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) {
8182
dirNodes := map[string]*WebDiffFileItem{"": &dft.TreeRoot}
8283
addItem := func(item *WebDiffFileItem) {
8384
var parentPath string
@@ -110,6 +111,7 @@ func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[st
110111
item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
111112
item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
112113
item.NameHash = git.HashFilePathForWebUI(item.FullName)
114+
item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{FullName: file.HeadPath, EntryMode: file.HeadMode})
113115

114116
switch file.HeadMode {
115117
case git.EntryModeTree:

0 commit comments

Comments
 (0)