Add material icons for file list (#33837)

pull/33667/head^2
wxiaoguang 3 days ago committed by GitHub
parent ae63568ce3
commit 34e5df6d30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -17,7 +17,6 @@ import (
"strings" "strings"
"time" "time"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -139,24 +138,3 @@ func Int64sToStrings(ints []int64) []string {
} }
return strs return strs
} }
// EntryIcon returns the octicon name for displaying files/directories
func EntryIcon(entry *git.TreeEntry) string {
switch {
case entry.IsLink():
te, err := entry.FollowLink()
if err != nil {
return "file-symlink-file"
}
if te.IsDir() {
return "file-directory-symlink"
}
return "file-symlink-file"
case entry.IsDir():
return "file-directory-fill"
case entry.IsSubModule():
return "file-submodule"
}
return "file"
}

@ -0,0 +1,27 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package fileicon
import (
"html/template"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/svg"
)
func BasicThemeIcon(entry *git.TreeEntry) template.HTML {
svgName := "octicon-file"
switch {
case entry.IsLink():
svgName = "octicon-file-symlink-file"
if te, err := entry.FollowLink(); err == nil && te.IsDir() {
svgName = "octicon-file-directory-symlink"
}
case entry.IsDir():
svgName = "octicon-file-directory-fill"
case entry.IsSubModule():
svgName = "octicon-file-submodule"
}
return svg.RenderHTML(svgName)
}

@ -0,0 +1,150 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package fileicon
import (
"html/template"
"path"
"strings"
"sync"
"code.gitea.io/gitea/modules/git"
"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"
)
type materialIconRulesData struct {
IconDefinitions map[string]*struct {
IconPath string `json:"iconPath"`
} `json:"iconDefinitions"`
FileNames map[string]string `json:"fileNames"`
FolderNames map[string]string `json:"folderNames"`
FileExtensions map[string]string `json:"fileExtensions"`
LanguageIDs map[string]string `json:"languageIds"`
}
type MaterialIconProvider struct {
once sync.Once
rules *materialIconRulesData
svgs map[string]string
}
var materialIconProvider MaterialIconProvider
func DefaultMaterialIconProvider() *MaterialIconProvider {
return &materialIconProvider
}
func (m *MaterialIconProvider) loadData() {
buf, err := options.AssetFS().ReadFile("fileicon/material-icon-rules.json")
if err != nil {
log.Error("Failed to read material icon rules: %v", err)
return
}
err = json.Unmarshal(buf, &m.rules)
if err != nil {
log.Error("Failed to unmarshal material icon rules: %v", err)
return
}
buf, err = options.AssetFS().ReadFile("fileicon/material-icon-svgs.json")
if err != nil {
log.Error("Failed to read material icon rules: %v", err)
return
}
err = json.Unmarshal(buf, &m.svgs)
if err != nil {
log.Error("Failed to unmarshal material icon rules: %v", err)
return
}
log.Debug("Loaded material icon rules and SVG images")
}
func (m *MaterialIconProvider) renderFileIconSVG(ctx reqctx.RequestContext, name, svg string) template.HTML {
data := ctx.GetData()
renderedSVGs, _ := data["_RenderedSVGs"].(map[string]bool)
if renderedSVGs == nil {
renderedSVGs = make(map[string]bool)
data["_RenderedSVGs"] = renderedSVGs
}
// 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 fileicon" 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>`)
}
svg = `<svg id="` + svgID + `" ` + svgCommonAttrs + svg[4:]
renderedSVGs[svgID] = true
return template.HTML(svg)
}
func (m *MaterialIconProvider) FileIcon(ctx reqctx.RequestContext, entry *git.TreeEntry) template.HTML {
m.once.Do(m.loadData)
if m.rules == nil {
return BasicThemeIcon(entry)
}
if entry.IsLink() {
if te, err := entry.FollowLink(); err == nil && te.IsDir() {
return svg.RenderHTML("material-folder-symlink")
}
return svg.RenderHTML("octicon-file-symlink-file") // TODO: find some better icons for them
}
name := m.findIconName(entry)
if name == "folder" {
// the material icon pack's "folder" icon doesn't look good, so use our built-in one
return svg.RenderHTML("material-folder-generic")
}
if iconSVG, ok := m.svgs[name]; ok && iconSVG != "" {
return m.renderFileIconSVG(ctx, name, iconSVG)
}
return svg.RenderHTML("octicon-file")
}
func (m *MaterialIconProvider) findIconName(entry *git.TreeEntry) string {
if entry.IsSubModule() {
return "folder-git"
}
iconsData := m.rules
fileName := path.Base(entry.Name())
if entry.IsDir() {
if s, ok := iconsData.FolderNames[fileName]; ok {
return s
}
if s, ok := iconsData.FolderNames[strings.ToLower(fileName)]; ok {
return s
}
return "folder"
}
if s, ok := iconsData.FileNames[fileName]; ok {
return s
}
if s, ok := iconsData.FileNames[strings.ToLower(fileName)]; ok {
return s
}
for i := len(fileName) - 1; i >= 0; i-- {
if fileName[i] == '.' {
ext := fileName[i+1:]
if s, ok := iconsData.FileExtensions[ext]; ok {
return s
}
}
}
return "file"
}

@ -94,6 +94,9 @@ type RequestContext interface {
} }
func FromContext(ctx context.Context) RequestContext { func FromContext(ctx context.Context) RequestContext {
if rc, ok := ctx.(RequestContext); ok {
return rc
}
// here we must use the current ctx and the underlying store // here we must use the current ctx and the underlying store
// the current ctx guarantees that the ctx deadline/cancellation/values are respected // the current ctx guarantees that the ctx deadline/cancellation/values are respected
// the underlying store guarantees that the request-specific data is available // the underlying store guarantees that the request-specific data is available
@ -134,6 +137,6 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co
// NewRequestContextForTest creates a new RequestContext for testing purposes // NewRequestContextForTest creates a new RequestContext for testing purposes
// It doesn't add the context to the process manager, nor do cleanup // It doesn't add the context to the process manager, nor do cleanup
func NewRequestContextForTest(parentCtx context.Context) context.Context { func NewRequestContextForTest(parentCtx context.Context) RequestContext {
return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}} return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}}
} }

@ -28,6 +28,7 @@ var UI = struct {
DefaultShowFullName bool DefaultShowFullName bool
DefaultTheme string DefaultTheme string
Themes []string Themes []string
FileIconTheme string
Reactions []string Reactions []string
ReactionsLookup container.Set[string] `ini:"-"` ReactionsLookup container.Set[string] `ini:"-"`
CustomEmojis []string CustomEmojis []string
@ -84,6 +85,7 @@ var UI = struct {
ReactionMaxUserNum: 10, ReactionMaxUserNum: 10,
MaxDisplayFileSize: 8388608, MaxDisplayFileSize: 8388608,
DefaultTheme: `gitea-auto`, DefaultTheme: `gitea-auto`,
FileIconTheme: `material`,
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`}, Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`}, CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"}, CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},

@ -59,7 +59,6 @@ func NewFuncMap() template.FuncMap {
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// svg / avatar / icon / color // svg / avatar / icon / color
"svg": svg.RenderHTML, "svg": svg.RenderHTML,
"EntryIcon": base.EntryIcon,
"MigrationIcon": migrationIcon, "MigrationIcon": migrationIcon,
"ActionIcon": actionIcon, "ActionIcon": actionIcon,
"SortArrow": sortArrow, "SortArrow": sortArrow,

@ -4,7 +4,6 @@
package templates package templates
import ( import (
"context"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"html/template" "html/template"
@ -16,20 +15,23 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/emoji" "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/htmlutil"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
type RenderUtils struct { type RenderUtils struct {
ctx context.Context ctx reqctx.RequestContext
} }
func NewRenderUtils(ctx context.Context) *RenderUtils { func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
return &RenderUtils{ctx: ctx} return &RenderUtils{ctx: ctx}
} }
@ -179,6 +181,13 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
textColor, itemColor, itemHTML) 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 // RenderEmoji renders html text with emoji post processors
func (ut *RenderUtils) RenderEmoji(text string) template.HTML { func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text)) renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))

@ -8,45 +8,46 @@ import (
"html/template" "html/template"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
) )
func renderEmojiLegacy(ctx context.Context, text string) template.HTML { func renderEmojiLegacy(ctx context.Context, text string) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderEmoji(text) return NewRenderUtils(reqctx.FromContext(ctx)).RenderEmoji(text)
} }
func renderLabelLegacy(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML { func renderLabelLegacy(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderLabel(label) return NewRenderUtils(reqctx.FromContext(ctx)).RenderLabel(label)
} }
func renderLabelsLegacy(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML { func renderLabelsLegacy(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderLabels(labels, repoLink, issue) return NewRenderUtils(reqctx.FromContext(ctx)).RenderLabels(labels, repoLink, issue)
} }
func renderMarkdownToHtmlLegacy(ctx context.Context, input string) template.HTML { //nolint:revive func renderMarkdownToHtmlLegacy(ctx context.Context, input string) template.HTML { //nolint:revive
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).MarkdownToHtml(input) return NewRenderUtils(reqctx.FromContext(ctx)).MarkdownToHtml(input)
} }
func renderCommitMessageLegacy(ctx context.Context, msg string, metas map[string]string) template.HTML { func renderCommitMessageLegacy(ctx context.Context, msg string, metas map[string]string) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderCommitMessage(msg, metas) return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitMessage(msg, metas)
} }
func renderCommitMessageLinkSubjectLegacy(ctx context.Context, msg, urlDefault string, metas map[string]string) template.HTML { func renderCommitMessageLinkSubjectLegacy(ctx context.Context, msg, urlDefault string, metas map[string]string) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderCommitMessageLinkSubject(msg, urlDefault, metas) return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitMessageLinkSubject(msg, urlDefault, metas)
} }
func renderIssueTitleLegacy(ctx context.Context, text string, metas map[string]string) template.HTML { func renderIssueTitleLegacy(ctx context.Context, text string, metas map[string]string) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderIssueTitle(text, metas) return NewRenderUtils(reqctx.FromContext(ctx)).RenderIssueTitle(text, metas)
} }
func renderCommitBodyLegacy(ctx context.Context, msg string, metas map[string]string) template.HTML { func renderCommitBodyLegacy(ctx context.Context, msg string, metas map[string]string) template.HTML {
panicIfDevOrTesting() panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderCommitBody(msg, metas) return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitBody(msg, metas)
} }

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
@ -67,9 +68,9 @@ func TestMain(m *testing.M) {
os.Exit(m.Run()) os.Exit(m.Run())
} }
func newTestRenderUtils() *RenderUtils { func newTestRenderUtils(t *testing.T) *RenderUtils {
ctx := context.Background() ctx := reqctx.NewRequestContextForTest(t.Context())
ctx = context.WithValue(ctx, translation.ContextKey, &translation.MockLocale{}) ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
return NewRenderUtils(ctx) return NewRenderUtils(ctx)
} }
@ -105,7 +106,7 @@ func TestRenderCommitBody(t *testing.T) {
want: "second line", want: "second line",
}, },
} }
ut := newTestRenderUtils() ut := newTestRenderUtils(t)
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, ut.RenderCommitBody(tt.args.msg, nil), "RenderCommitBody(%v, %v)", tt.args.msg, nil) assert.Equalf(t, tt.want, ut.RenderCommitBody(tt.args.msg, nil), "RenderCommitBody(%v, %v)", tt.args.msg, nil)
@ -131,17 +132,17 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
<a href="/mention-user">@mention-user</a> test <a href="/mention-user">@mention-user</a> test
<a href="/user13/repo11/issues/123" class="ref-issue">#123</a> <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
space` space`
assert.EqualValues(t, expected, string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas))) assert.EqualValues(t, expected, string(newTestRenderUtils(t).RenderCommitBody(testInput(), testMetas)))
} }
func TestRenderCommitMessage(t *testing.T) { func TestRenderCommitMessage(t *testing.T) {
expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> ` expected := `space <a href="/mention-user" data-markdown-generated-content="">@mention-user</a> `
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessage(testInput(), testMetas))
} }
func TestRenderCommitMessageLinkSubject(t *testing.T) { func TestRenderCommitMessageLinkSubject(t *testing.T) {
expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>` expected := `<a href="https://example.com/link" class="muted">space </a><a href="/mention-user" data-markdown-generated-content="">@mention-user</a>`
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas)) assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
} }
func TestRenderIssueTitle(t *testing.T) { func TestRenderIssueTitle(t *testing.T) {
@ -168,7 +169,7 @@ mail@domain.com
space<SPACE><SPACE> space<SPACE><SPACE>
` `
expected = strings.ReplaceAll(expected, "<SPACE>", " ") expected = strings.ReplaceAll(expected, "<SPACE>", " ")
assert.EqualValues(t, expected, string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas))) assert.EqualValues(t, expected, string(newTestRenderUtils(t).RenderIssueTitle(testInput(), testMetas)))
} }
func TestRenderMarkdownToHtml(t *testing.T) { func TestRenderMarkdownToHtml(t *testing.T) {
@ -194,11 +195,11 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
#123 #123
space</p> space</p>
` `
assert.Equal(t, expected, string(newTestRenderUtils().MarkdownToHtml(testInput()))) assert.Equal(t, expected, string(newTestRenderUtils(t).MarkdownToHtml(testInput())))
} }
func TestRenderLabels(t *testing.T) { func TestRenderLabels(t *testing.T) {
ut := newTestRenderUtils() ut := newTestRenderUtils(t)
label := &issues.Label{ID: 123, Name: "label-name", Color: "label-color"} label := &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
issue := &issues.Issue{} issue := &issues.Issue{}
expected := `/owner/repo/issues?labels=123` expected := `/owner/repo/issues?labels=123`
@ -212,6 +213,6 @@ func TestRenderLabels(t *testing.T) {
func TestUserMention(t *testing.T) { func TestUserMention(t *testing.T) {
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
rendered := newTestRenderUtils().MarkdownToHtml("@no-such-user @mention-user @mention-user") rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user")
assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered))) assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

110
package-lock.json generated

@ -100,6 +100,7 @@
"eslint-plugin-wc": "2.2.0", "eslint-plugin-wc": "2.2.0",
"happy-dom": "17.1.0", "happy-dom": "17.1.0",
"markdownlint-cli": "0.44.0", "markdownlint-cli": "0.44.0",
"material-icon-theme": "5.20.0",
"nolyfill": "1.0.43", "nolyfill": "1.0.43",
"postcss-html": "1.8.0", "postcss-html": "1.8.0",
"stylelint": "16.14.1", "stylelint": "16.14.1",
@ -4675,6 +4676,13 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/chroma-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.1.2.tgz",
"integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==",
"dev": true,
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/chrome-trace-event": { "node_modules/chrome-trace-event": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@ -5702,6 +5710,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/deep-rename-keys": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz",
"integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"kind-of": "^3.0.2",
"rename-keys": "^1.1.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-rename-keys/node_modules/kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-buffer": "^1.1.5"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delaunator": { "node_modules/delaunator": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
@ -7004,6 +7039,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"dev": true,
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -7856,6 +7898,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"dev": true,
"license": "MIT"
},
"node_modules/is-builtin-module": { "node_modules/is-builtin-module": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
@ -8840,6 +8889,25 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/material-icon-theme": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.20.0.tgz",
"integrity": "sha512-EAz5I2O7Hq6G8Rv0JdO6NXL+jK/mvDppcVUVbsUMpSqSmFczNdaR5WJ3lOiRz4HNBlEN2i2sVSfuqI5iNQfGLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chroma-js": "^3.0.0",
"events": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"svgson": "^5.3.1"
},
"engines": {
"vscode": "^1.55.0"
},
"funding": {
"url": "https://github.com/sponsors/material-extensions"
}
},
"node_modules/mathml-tag-names": { "node_modules/mathml-tag-names": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz",
@ -11020,6 +11088,16 @@
"jsesc": "bin/jsesc" "jsesc": "bin/jsesc"
} }
}, },
"node_modules/rename-keys": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz",
"integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -12258,6 +12336,17 @@
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/svgson": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz",
"integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-rename-keys": "^0.2.1",
"xml-reader": "2.4.3"
}
},
"node_modules/swagger-ui-dist": { "node_modules/swagger-ui-dist": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.3.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.3.tgz",
@ -14107,6 +14196,16 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/xml-lexer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz",
"integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@ -14117,6 +14216,17 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/xml-reader": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz",
"integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0",
"xml-lexer": "^0.2.2"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

@ -99,6 +99,7 @@
"eslint-plugin-wc": "2.2.0", "eslint-plugin-wc": "2.2.0",
"happy-dom": "17.1.0", "happy-dom": "17.1.0",
"markdownlint-cli": "0.44.0", "markdownlint-cli": "0.44.0",
"material-icon-theme": "5.20.0",
"nolyfill": "1.0.43", "nolyfill": "1.0.43",
"postcss-html": "1.8.0", "postcss-html": "1.8.0",
"stylelint": "16.14.1", "stylelint": "16.14.1",

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="svg material-folder-generic" width="16" height="16" aria-hidden="true"><path fill="#42a5f5" d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8z"/></svg>

After

Width:  |  Height:  |  Size: 250 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="svg material-folder-symlink" width="16" height="16" aria-hidden="true"><path fill="#42a5f5" d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8z" opacity=".745"/><path fill="#c5e5fd" d="M16.972 10.757v2.641h-6.561v5.281h6.561v2.641l6.562-5.281z" opacity=".81"/></svg>

After

Width:  |  Height:  |  Size: 364 B

@ -15,8 +15,8 @@
{{$commit := $item.Commit}} {{$commit := $item.Commit}}
{{$submoduleFile := $item.SubmoduleFile}} {{$submoduleFile := $item.SubmoduleFile}}
<div class="repo-file-cell name {{if not $commit}}notready{{end}}"> <div class="repo-file-cell name {{if not $commit}}notready{{end}}">
{{ctx.RenderUtils.RenderFileIcon $entry}}
{{if $entry.IsSubModule}} {{if $entry.IsSubModule}}
{{svg "octicon-file-submodule"}}
{{$submoduleLink := $submoduleFile.SubmoduleWebLink ctx}} {{$submoduleLink := $submoduleFile.SubmoduleWebLink ctx}}
{{if $submoduleLink}} {{if $submoduleLink}}
<a class="muted" href="{{$submoduleLink.RepoWebLink}}">{{$entry.Name}}</a> <span class="at">@</span> <a href="{{$submoduleLink.CommitWebLink}}">{{ShortSha $submoduleFile.RefID}}</a> <a class="muted" href="{{$submoduleLink.RepoWebLink}}">{{$entry.Name}}</a> <span class="at">@</span> <a href="{{$submoduleLink.CommitWebLink}}">{{ShortSha $submoduleFile.RefID}}</a>
@ -26,7 +26,6 @@
{{else}} {{else}}
{{if $entry.IsDir}} {{if $entry.IsDir}}
{{$subJumpablePathName := $entry.GetSubJumpablePathName}} {{$subJumpablePathName := $entry.GetSubJumpablePathName}}
{{svg "octicon-file-directory-fill"}}
<a class="muted" href="{{$.TreeLink}}/{{PathEscapeSegments $subJumpablePathName}}" title="{{$subJumpablePathName}}"> <a class="muted" href="{{$.TreeLink}}/{{PathEscapeSegments $subJumpablePathName}}" title="{{$subJumpablePathName}}">
{{$subJumpablePathFields := StringUtils.Split $subJumpablePathName "/"}} {{$subJumpablePathFields := StringUtils.Split $subJumpablePathName "/"}}
{{$subJumpablePathFieldLast := (Eval (len $subJumpablePathFields) "-" 1)}} {{$subJumpablePathFieldLast := (Eval (len $subJumpablePathFields) "-" 1)}}
@ -38,7 +37,6 @@
{{end}} {{end}}
</a> </a>
{{else}} {{else}}
{{svg (printf "octicon-%s" (EntryIcon $entry))}}
<a class="muted" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}" title="{{$entry.Name}}">{{$entry.Name}}</a> <a class="muted" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}" title="{{$entry.Name}}">{{$entry.Name}}</a>
{{end}} {{end}}
{{end}} {{end}}

@ -164,7 +164,7 @@ func TestViewRepo1CloneLinkAuthorized(t *testing.T) {
func TestViewRepoWithSymlinks(t *testing.T) { func TestViewRepoWithSymlinks(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.UI.FileIconTheme, "basic")()
session := loginUser(t, "user2") session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/repo20.git") req := NewRequest(t, "GET", "/user2/repo20.git")

@ -5,27 +5,20 @@ import {parse} from 'node:path';
import {readFile, writeFile, mkdir} from 'node:fs/promises'; import {readFile, writeFile, mkdir} from 'node:fs/promises';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import {exit} from 'node:process'; import {exit} from 'node:process';
import * as fs from 'node:fs';
const glob = (pattern) => fastGlob.sync(pattern, { const glob = (pattern) => fastGlob.sync(pattern, {
cwd: fileURLToPath(new URL('..', import.meta.url)), cwd: fileURLToPath(new URL('..', import.meta.url)),
absolute: true, absolute: true,
}); });
function doExit(err) { async function processAssetsSvgFile(file, {prefix, fullName} = {}) {
if (err) console.error(err); let name = fullName;
exit(err ? 1 : 0); if (!name) {
}
async function processFile(file, {prefix, fullName} = {}) {
let name;
if (fullName) {
name = fullName;
} else {
name = parse(file).name; name = parse(file).name;
if (prefix) name = `${prefix}-${name}`; if (prefix) name = `${prefix}-${name}`;
if (prefix === 'octicon') name = name.replace(/-[0-9]+$/, ''); // chop of '-16' on octicons if (prefix === 'octicon') name = name.replace(/-[0-9]+$/, ''); // chop of '-16' on octicons
} }
// Set the `xmlns` attribute so that the files are displayable in standalone documents // Set the `xmlns` attribute so that the files are displayable in standalone documents
// The svg backend module will strip the attribute during startup for inline display // The svg backend module will strip the attribute during startup for inline display
const {data} = optimize(await readFile(file, 'utf8'), { const {data} = optimize(await readFile(file, 'utf8'), {
@ -44,28 +37,50 @@ async function processFile(file, {prefix, fullName} = {}) {
}, },
], ],
}); });
await writeFile(fileURLToPath(new URL(`../public/assets/img/svg/${name}.svg`, import.meta.url)), data); await writeFile(fileURLToPath(new URL(`../public/assets/img/svg/${name}.svg`, import.meta.url)), data);
} }
function processFiles(pattern, opts) { function processAssetsSvgFiles(pattern, opts) {
return glob(pattern).map((file) => processFile(file, opts)); return glob(pattern).map((file) => processAssetsSvgFile(file, opts));
}
async function processMaterialFileIcons() {
const files = glob('node_modules/material-icon-theme/icons/*.svg');
const svgSymbols = {};
for (const file of files) {
// remove all unnecessary attributes, only keep "viewBox"
const {data} = optimize(await readFile(file, 'utf8'), {
plugins: [
{name: 'preset-default'},
{name: 'removeDimensions'},
{name: 'removeXMLNS'},
{name: 'removeAttrs', params: {attrs: 'xml:space', elemSeparator: ','}},
],
});
const svgName = parse(file).name;
// intentionally use single quote here to avoid escaping
svgSymbols[svgName] = data.replace(/"/g, `'`);
}
fs.writeFileSync(fileURLToPath(new URL(`../options/fileicon/material-icon-svgs.json`, import.meta.url)), JSON.stringify(svgSymbols, null, 2));
const iconRules = await readFile(fileURLToPath(new URL(`../node_modules/material-icon-theme/dist/material-icons.json`, import.meta.url)));
const iconRulesPretty = JSON.stringify(JSON.parse(iconRules), null, 2);
fs.writeFileSync(fileURLToPath(new URL(`../options/fileicon/material-icon-rules.json`, import.meta.url)), iconRulesPretty);
} }
async function main() { async function main() {
try {
await mkdir(fileURLToPath(new URL('../public/assets/img/svg', import.meta.url)), {recursive: true}); await mkdir(fileURLToPath(new URL('../public/assets/img/svg', import.meta.url)), {recursive: true});
} catch {}
await Promise.all([ await Promise.all([
...processFiles('node_modules/@primer/octicons/build/svg/*-16.svg', {prefix: 'octicon'}), ...processAssetsSvgFiles('node_modules/@primer/octicons/build/svg/*-16.svg', {prefix: 'octicon'}),
...processFiles('web_src/svg/*.svg'), ...processAssetsSvgFiles('web_src/svg/*.svg'),
...processFiles('public/assets/img/gitea.svg', {fullName: 'gitea-gitea'}), ...processAssetsSvgFiles('public/assets/img/gitea.svg', {fullName: 'gitea-gitea'}),
processMaterialFileIcons(),
]); ]);
} }
try { try {
doExit(await main()); await main();
} catch (err) { } catch (err) {
doExit(err); console.error(err);
exit(1);
} }

@ -4,6 +4,10 @@
fill: currentcolor; fill: currentcolor;
} }
.svg.fileicon {
fill: transparent; /* some material icons have dark background fill, so need to reset */
}
.middle .svg { .middle .svg {
vertical-align: middle; vertical-align: middle;
} }

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" fill="#42a5f5"/></svg>

After

Width:  |  Height:  |  Size: 195 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H4c-1.11 0-2 .89-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" fill="#42a5f5" opacity=".745"/><path d="M16.972 10.757v2.641h-6.561v5.281h6.561v2.641l6.562-5.281-6.562-5.282z" opacity=".81" fill="#c5e5fd"/></svg>

After

Width:  |  Height:  |  Size: 321 B

Loading…
Cancel
Save