Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,9 @@ LEVEL = Info
;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css"
;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.
;; 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
Expand Down
22 changes: 0 additions & 22 deletions modules/base/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"strings"
"time"

"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"

Expand Down Expand Up @@ -139,24 +138,3 @@ func Int64sToStrings(ints []int64) []string {
}
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"
}
27 changes: 27 additions & 0 deletions modules/fileicon/basic.go
Original file line number Diff line number Diff line change
@@ -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)
}
150 changes: 150 additions & 0 deletions modules/fileicon/material.go
Original file line number Diff line number Diff line change
@@ -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"
}
5 changes: 4 additions & 1 deletion modules/reqctx/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ type RequestContext interface {
}

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
// the current ctx guarantees that the ctx deadline/cancellation/values are respected
// the underlying store guarantees that the request-specific data is available
Expand Down Expand Up @@ -134,6 +137,6 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co

// NewRequestContextForTest creates a new RequestContext for testing purposes
// 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)}}
}
2 changes: 2 additions & 0 deletions modules/setting/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var UI = struct {
DefaultShowFullName bool
DefaultTheme string
Themes []string
FileIconTheme string
Reactions []string
ReactionsLookup container.Set[string] `ini:"-"`
CustomEmojis []string
Expand Down Expand Up @@ -84,6 +85,7 @@ var UI = struct {
ReactionMaxUserNum: 10,
MaxDisplayFileSize: 8388608,
DefaultTheme: `gitea-auto`,
FileIconTheme: `material`,
Reactions: []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
CustomEmojis: []string{`git`, `gitea`, `codeberg`, `gitlab`, `github`, `gogs`},
CustomEmojisMap: map[string]string{"git": ":git:", "gitea": ":gitea:", "codeberg": ":codeberg:", "gitlab": ":gitlab:", "github": ":github:", "gogs": ":gogs:"},
Expand Down
1 change: 0 additions & 1 deletion modules/templates/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ func NewFuncMap() template.FuncMap {
// -----------------------------------------------------------------
// svg / avatar / icon / color
"svg": svg.RenderHTML,
"EntryIcon": base.EntryIcon,
"MigrationIcon": migrationIcon,
"ActionIcon": actionIcon,
"SortArrow": sortArrow,
Expand Down
15 changes: 12 additions & 3 deletions modules/templates/util_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package templates

import (
"context"
"encoding/hex"
"fmt"
"html/template"
Expand All @@ -16,20 +15,23 @@ 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"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
)

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}
}

Expand Down Expand Up @@ -179,6 +181,13 @@ 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))
Expand Down
17 changes: 9 additions & 8 deletions modules/templates/util_render_legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,46 @@ import (
"html/template"

issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/translation"
)

func renderEmojiLegacy(ctx context.Context, text string) template.HTML {
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 {
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 {
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
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 {
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 {
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 {
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 {
panicIfDevOrTesting()
return NewRenderUtils(ctx).RenderCommitBody(msg, metas)
return NewRenderUtils(reqctx.FromContext(ctx)).RenderCommitBody(msg, metas)
}
Loading