diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 0c76bbc6cdeef..1d8676a41a940 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1262,6 +1262,9 @@ LEVEL = Info ;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css" ;THEMES = ;; +;; Icon theme used by the file tree. Icons have to be place in data/icons//. Default is "". +;FILE_ICON_THEME = +;; ;; 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 diff --git a/modules/base/tool.go b/modules/base/tool.go index 9e43030f40019..420cc14a83418 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -21,8 +21,6 @@ import ( "time" "unicode/utf8" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "github.com/dustin/go-humanize" @@ -165,28 +163,6 @@ func Int64sToStrings(ints []int64) []string { return strs } -// EntryIcon returns the octicon class for displaying files/directories -func EntryIcon(entry *git.TreeEntry) string { - switch { - case entry.IsLink(): - te, err := entry.FollowLink() - if err != nil { - log.Debug(err.Error()) - 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" -} - // SetupGiteaRoot Sets GITEA_ROOT if it is not already set and returns the value func SetupGiteaRoot() string { giteaRoot := os.Getenv("GITEA_ROOT") diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index 4af8b9bc4d528..8ff329fec4519 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -168,8 +168,6 @@ func TestInt64sToStrings(t *testing.T) { ) } -// TODO: Test EntryIcon - func TestSetupGiteaRoot(t *testing.T) { _ = os.Setenv("GITEA_ROOT", "test") assert.Equal(t, "test", SetupGiteaRoot()) diff --git a/modules/fileicon/fileicon.go b/modules/fileicon/fileicon.go new file mode 100644 index 0000000000000..daf2df35f521e --- /dev/null +++ b/modules/fileicon/fileicon.go @@ -0,0 +1,105 @@ +package fileicon + +import ( + "context" + "html/template" + "path" + "strings" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" + lru "github.com/hashicorp/golang-lru/v2" +) + +var fileIconCache *lru.Cache[string, string] + +func init() { + var err error + fileIconCache, err = lru.New[string, string](1000) + if err != nil { + log.Fatal("Failed to create file icon cache: %v", err) + } +} + +func getBasicFileIconName(entry *git.TreeEntry) string { + switch { + case entry.IsLink(): + te, err := entry.FollowLink() + if err != nil { + log.Debug(err.Error()) + return "octicon-file-symlink-file" + } + if te.IsDir() { + return "octicon-file-directory-symlink" + } + return "octicon-file-symlink-file" + case entry.IsDir(): + return "octicon-file-directory-fill" + case entry.IsSubModule(): + return "octicon-file-submodule" + } + + return "octicon-file" +} + +// getFileIconNames returns a list of possible icon names for a file or directory +// Folder named `sub-folder` => +// - `folder_sub-folder“ (. will be replaced with _) +// - `folder` +// +// File named `.gitignore` => +// - `file__gitignore` (. will be replaced with _) +// - `file_` +// +// File named `README.md` => +// - `file_readme_md` +// - `file_md` +func getFileIconNames(entry *git.TreeEntry) []string { + fileName := strings.ReplaceAll(strings.ToLower(path.Base(entry.Name())), ".", "_") + + if entry.IsDir() { + return []string{"folder_" + fileName, "folder"} + } + + if entry.IsRegular() { + ext := strings.ToLower(strings.TrimPrefix(path.Ext(entry.Name()), ".")) + return []string{"file_" + fileName, "file_" + ext, "file"} + } + + return nil +} + +type fileIconBackend interface { + GetIcon(string) (string, error) +} + +// FileIcon returns a custom icon from a folder or the default octicon for displaying files/directories +func FileIcon(ctx context.Context, entry *git.TreeEntry) template.HTML { + backend := &fileIconHTTPBackend{ + theme: setting.UI.FileIconTheme, + baseURL: "https://raw.githubusercontent.com/anbraten/gitea-icons/refs/heads/master/gitea/", + } + + iconTheme := setting.UI.FileIconTheme + if iconTheme != "" { + iconNames := getFileIconNames(entry) + + // Try to load the custom icon + for _, iconName := range iconNames { + if icon, err := backend.GetIcon(iconName); err == nil { + if icon, ok := fileIconCache.Get(iconName); ok { + return svg.RenderHTMLFromString(icon) + } + + fileIconCache.Add(iconName, string(icon)) + + return svg.RenderHTMLFromString(string(icon)) + } + } + } + + // If no custom icon was found or an error occurred, return the default icon + return svg.RenderHTML(getBasicFileIconName(entry)) +} diff --git a/modules/fileicon/fs.go b/modules/fileicon/fs.go new file mode 100644 index 0000000000000..89b8b1ae50700 --- /dev/null +++ b/modules/fileicon/fs.go @@ -0,0 +1,31 @@ +package fileicon + +import ( + "os" + "path" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +type fileIconFolderBackend struct { + theme string +} + +func (f *fileIconFolderBackend) GetIcon(iconName string) (string, error) { + iconPath := path.Join(setting.AppDataPath, "icons", f.theme, iconName+".svg") + + log.Debug("Loading custom icon from %s", iconPath) + + // Try to load the icon from the filesystem + if _, err := os.Stat(iconPath); err != nil { + return "", err + } + + iconData, err := os.ReadFile(iconPath) + if err != nil { + return "", err + } + + return string(iconData), nil +} diff --git a/modules/fileicon/http.go b/modules/fileicon/http.go new file mode 100644 index 0000000000000..a1fa3aae49695 --- /dev/null +++ b/modules/fileicon/http.go @@ -0,0 +1,38 @@ +package fileicon + +import ( + "fmt" + "io" + "net/http" + "path" + + "code.gitea.io/gitea/modules/log" +) + +type fileIconHTTPBackend struct { + theme string + baseURL string +} + +func (f *fileIconHTTPBackend) GetIcon(iconName string) (string, error) { + iconPath := path.Join(f.baseURL, f.theme, iconName+".svg") + + log.Info("Loading custom icon from %s", iconPath) + + // Try to load the icon via HTTP get + res, err := http.Get(iconPath) + if err != nil { + return "", err + } + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("Failed to load icon: %s", res.Status) + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(resBody), nil +} diff --git a/modules/setting/ui.go b/modules/setting/ui.go index a8dc11d09713c..2aa7407d2f3b5 100644 --- a/modules/setting/ui.go +++ b/modules/setting/ui.go @@ -28,6 +28,7 @@ var UI = struct { DefaultShowFullName bool DefaultTheme string Themes []string + FileIconTheme string Reactions []string ReactionsLookup container.Set[string] `ini:"-"` CustomEmojis []string diff --git a/modules/svg/svg.go b/modules/svg/svg.go index 8132978caca99..b18e5c5d93eb3 100644 --- a/modules/svg/svg.go +++ b/modules/svg/svg.go @@ -60,16 +60,26 @@ func MockIcon(icon string) func() { func RenderHTML(icon string, others ...any) template.HTML { size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...) if svgStr, ok := svgIcons[icon]; ok { - // the code is somewhat hacky, but it just works, because the SVG contents are all normalized - if size != defaultSize { - svgStr = strings.Replace(svgStr, fmt.Sprintf(`width="%d"`, defaultSize), fmt.Sprintf(`width="%d"`, size), 1) - svgStr = strings.Replace(svgStr, fmt.Sprintf(`height="%d"`, defaultSize), fmt.Sprintf(`height="%d"`, size), 1) - } - if class != "" { - svgStr = strings.Replace(svgStr, `class="`, fmt.Sprintf(`class="%s `, class), 1) - } - return template.HTML(svgStr) + return RenderHTMLFromString(svgStr, size, class) } + // during test (or something wrong happens), there is no SVG loaded, so use a dummy span to tell that the icon is missing return template.HTML(fmt.Sprintf("%s(%d/%s)", template.HTMLEscapeString(icon), size, template.HTMLEscapeString(class))) } + +// RenderHTMLFromString renders icons from a string - arguments SVG string (string), size (int), class (string) +func RenderHTMLFromString(svgStr string, others ...any) template.HTML { + svgStr = string(Normalize([]byte(svgStr), defaultSize)) + + size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...) + + // the code is somewhat hacky, but it just works, because the SVG contents are all normalized + if size != defaultSize { + svgStr = strings.Replace(svgStr, fmt.Sprintf(`width="%d"`, defaultSize), fmt.Sprintf(`width="%d"`, size), 1) + svgStr = strings.Replace(svgStr, fmt.Sprintf(`height="%d"`, defaultSize), fmt.Sprintf(`height="%d"`, size), 1) + } + if class != "" { + svgStr = strings.Replace(svgStr, `class="`, fmt.Sprintf(`class="%s `, class), 1) + } + return template.HTML(svgStr) +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 5f73e6b278cdb..3f31e3e17c53a 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -16,6 +16,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/svg" @@ -58,7 +59,7 @@ func NewFuncMap() template.FuncMap { // ----------------------------------------------------------------- // svg / avatar / icon / color "svg": svg.RenderHTML, - "EntryIcon": base.EntryIcon, + "FileIcon": fileicon.FileIcon, "MigrationIcon": migrationIcon, "ActionIcon": actionIcon, "SortArrow": sortArrow, diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl index 7ec9acc84ecc1..1bc4f0e5a1272 100644 --- a/templates/repo/view_list.tmpl +++ b/templates/repo/view_list.tmpl @@ -24,8 +24,8 @@ + {{FileIcon $.Context $entry}} {{if $entry.IsSubModule}} - {{svg "octicon-file-submodule"}} {{$refURL := $subModuleFile.RefURL AppUrl $.Repository.FullName $.SSHDomain}} {{/* FIXME: the usage of AppUrl seems incorrect, it would be fixed in the future, use AppSubUrl instead */}} {{if $refURL}} {{$entry.Name}}@{{ShortSha $subModuleFile.RefID}} @@ -35,7 +35,6 @@ {{else}} {{if $entry.IsDir}} {{$subJumpablePathName := $entry.GetSubJumpablePathName}} - {{svg "octicon-file-directory-fill"}} {{$subJumpablePathFields := StringUtils.Split $subJumpablePathName "/"}} {{$subJumpablePathFieldLast := (Eval (len $subJumpablePathFields) "-" 1)}} @@ -47,7 +46,6 @@ {{end}} {{else}} - {{svg (printf "octicon-%s" (EntryIcon $entry))}} {{$entry.Name}} {{end}} {{end}}