From fbebdfbb8d84e4549048444ef651ce7a7d1a39c3 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Wed, 23 Oct 2024 19:53:58 +0200 Subject: [PATCH 1/5] Allow custom file icons --- modules/base/tool.go | 24 ---------- modules/base/tool_test.go | 2 - modules/fileicon/icon.go | 86 +++++++++++++++++++++++++++++++++++ modules/templates/helper.go | 3 +- templates/repo/view_list.tmpl | 4 +- 5 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 modules/fileicon/icon.go 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/icon.go b/modules/fileicon/icon.go new file mode 100644 index 0000000000000..3ec762bbae4aa --- /dev/null +++ b/modules/fileicon/icon.go @@ -0,0 +1,86 @@ +package fileicon + +import ( + "context" + "html/template" + "os" + "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" +) + +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" +} + +func getFileIconNames(entry *git.TreeEntry) []string { + if entry.IsDir() { + return []string{"directory"} + } + + if entry.IsRegular() { + fileName := strings.ToLower(path.Base(entry.Name())) + ext := strings.ToLower(strings.TrimPrefix(path.Ext(fileName), ".")) + return []string{fileName, ext} + } + + return nil +} + +func loadCustomIcon(iconPath string) (string, error) { + // Try to load the icon from the filesystem + if _, err := os.Stat(iconPath); err != nil { + return "", err + } + + // Read the SVG file + iconData, err := os.ReadFile(iconPath) + if err != nil { + return "", err + } + + return string(iconData), nil +} + +// 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 { + iconPack, ok := ctx.Value("icon-pack").(string) // TODO: allow user to select an icon pack from a list + iconPack = "demo" + ok = true + + if ok && iconPack != "" { + iconNames := getFileIconNames(entry) + + // Try to load the custom icon + for _, iconName := range iconNames { + iconPath := path.Join(setting.AppDataPath, "icons", iconPack, iconName+".svg") + if icon, err := loadCustomIcon(iconPath); err == nil { + return template.HTML(icon) + } + } + } + + // If no custom icon was found or an error occurred, return the default icon + return svg.RenderHTML(getBasicFileIconName(entry)) +} 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}} From 0f928b0deaa8a17227cf8dbd771525076e9df2e7 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Wed, 23 Oct 2024 20:04:13 +0200 Subject: [PATCH 2/5] enhance loading --- modules/fileicon/icon.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/fileicon/icon.go b/modules/fileicon/icon.go index 3ec762bbae4aa..1f610ac89a337 100644 --- a/modules/fileicon/icon.go +++ b/modules/fileicon/icon.go @@ -36,13 +36,14 @@ func getBasicFileIconName(entry *git.TreeEntry) string { func getFileIconNames(entry *git.TreeEntry) []string { if entry.IsDir() { - return []string{"directory"} + fileName := strings.ToLower(path.Base(entry.Name())) + return []string{fileName, "directory"} } if entry.IsRegular() { fileName := strings.ToLower(path.Base(entry.Name())) ext := strings.ToLower(strings.TrimPrefix(path.Ext(fileName), ".")) - return []string{fileName, ext} + return []string{fileName, ext, "file"} } return nil From 458de74bdd82bfe7b55f8614b864eb456b50ded5 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Wed, 23 Oct 2024 21:43:42 +0200 Subject: [PATCH 3/5] Add icon cache, get icon theme from config --- custom/conf/app.example.ini | 3 ++ modules/fileicon/{icon.go => fileicon.go} | 39 ++++++++++++++++------- modules/setting/ui.go | 1 + modules/svg/svg.go | 28 ++++++++++------ 4 files changed, 50 insertions(+), 21 deletions(-) rename modules/fileicon/{icon.go => fileicon.go} (68%) 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/fileicon/icon.go b/modules/fileicon/fileicon.go similarity index 68% rename from modules/fileicon/icon.go rename to modules/fileicon/fileicon.go index 1f610ac89a337..8918e326d6bf3 100644 --- a/modules/fileicon/icon.go +++ b/modules/fileicon/fileicon.go @@ -11,8 +11,19 @@ import ( "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(): @@ -35,49 +46,53 @@ func getBasicFileIconName(entry *git.TreeEntry) string { } func getFileIconNames(entry *git.TreeEntry) []string { + fileName := strings.ToLower(path.Base(entry.Name())) + if entry.IsDir() { - fileName := strings.ToLower(path.Base(entry.Name())) - return []string{fileName, "directory"} + return []string{"folder_" + fileName, "folder"} } if entry.IsRegular() { - fileName := strings.ToLower(path.Base(entry.Name())) ext := strings.ToLower(strings.TrimPrefix(path.Ext(fileName), ".")) - return []string{fileName, ext, "file"} + return []string{"file_" + fileName, "file_" + ext, "file"} } return nil } func loadCustomIcon(iconPath string) (string, error) { + log.Info("Loading custom icon from %s", iconPath) + + if icon, ok := fileIconCache.Get(iconPath); ok { + return icon, nil + } + // Try to load the icon from the filesystem if _, err := os.Stat(iconPath); err != nil { return "", err } - // Read the SVG file iconData, err := os.ReadFile(iconPath) if err != nil { return "", err } + fileIconCache.Add(iconPath, string(iconData)) + return string(iconData), nil } // 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 { - iconPack, ok := ctx.Value("icon-pack").(string) // TODO: allow user to select an icon pack from a list - iconPack = "demo" - ok = true - - if ok && iconPack != "" { + iconTheme := setting.UI.FileIconTheme + if iconTheme != "" { iconNames := getFileIconNames(entry) // Try to load the custom icon for _, iconName := range iconNames { - iconPath := path.Join(setting.AppDataPath, "icons", iconPack, iconName+".svg") + iconPath := path.Join(setting.AppDataPath, "icons", iconTheme, iconName+".svg") if icon, err := loadCustomIcon(iconPath); err == nil { - return template.HTML(icon) + return svg.RenderHTMLFromString(icon) } } } 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) +} From f11c4cc4c1790f4d6fe4d06f4cc02b5e57d2bf35 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Wed, 23 Oct 2024 22:25:26 +0200 Subject: [PATCH 4/5] enhance --- modules/fileicon/fileicon.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/modules/fileicon/fileicon.go b/modules/fileicon/fileicon.go index 8918e326d6bf3..30a4b28d48221 100644 --- a/modules/fileicon/fileicon.go +++ b/modules/fileicon/fileicon.go @@ -45,15 +45,27 @@ func getBasicFileIconName(entry *git.TreeEntry) string { 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.ToLower(path.Base(entry.Name())) + 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(fileName), ".")) + ext := strings.ToLower(strings.TrimPrefix(path.Ext(entry.Name()), ".")) return []string{"file_" + fileName, "file_" + ext, "file"} } @@ -61,7 +73,7 @@ func getFileIconNames(entry *git.TreeEntry) []string { } func loadCustomIcon(iconPath string) (string, error) { - log.Info("Loading custom icon from %s", iconPath) + log.Debug("Loading custom icon from %s", iconPath) if icon, ok := fileIconCache.Get(iconPath); ok { return icon, nil From ca3974b8af0ac31721d68e37b6bcd37bd2d2e024 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Thu, 24 Oct 2024 08:37:20 +0200 Subject: [PATCH 5/5] add sample http icon backend --- modules/fileicon/fileicon.go | 39 ++++++++++++++---------------------- modules/fileicon/fs.go | 31 ++++++++++++++++++++++++++++ modules/fileicon/http.go | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 modules/fileicon/fs.go create mode 100644 modules/fileicon/http.go diff --git a/modules/fileicon/fileicon.go b/modules/fileicon/fileicon.go index 30a4b28d48221..daf2df35f521e 100644 --- a/modules/fileicon/fileicon.go +++ b/modules/fileicon/fileicon.go @@ -3,7 +3,6 @@ package fileicon import ( "context" "html/template" - "os" "path" "strings" @@ -72,39 +71,31 @@ func getFileIconNames(entry *git.TreeEntry) []string { return nil } -func loadCustomIcon(iconPath string) (string, error) { - log.Debug("Loading custom icon from %s", iconPath) - - if icon, ok := fileIconCache.Get(iconPath); ok { - return icon, nil - } - - // 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 - } - - fileIconCache.Add(iconPath, string(iconData)) - - return string(iconData), 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 { - iconPath := path.Join(setting.AppDataPath, "icons", iconTheme, iconName+".svg") - if icon, err := loadCustomIcon(iconPath); err == nil { - return svg.RenderHTMLFromString(icon) + 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)) } } } 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 +}