diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..eda1abeeeb
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,7 @@
+# Instructions for agents
+
+- Use `make help` to find available development targets
+- Before committing go code changes, run `make fmt`
+- Before committing `go.mod` changes, run `make tidy`
+- Before committing new `.go` files, add the current year into the copyright header
+- Before committing files, removed any trailing whitespace
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 3eaffde970..084c66aab0 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1329,9 +1329,12 @@ 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.
+;; The icon theme for files (basic/material)
;FILE_ICON_THEME = material
;;
+;; The icon theme for folders (basic/material)
+;FOLDER_ICON_THEME = basic
+;;
;; 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/render.go b/modules/fileicon/render.go
index 6b2fcfa81e..5bf2a3a02e 100644
--- a/modules/fileicon/render.go
+++ b/modules/fileicon/render.go
@@ -34,7 +34,13 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML {
}
func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML {
- if setting.UI.FileIconTheme == "material" {
+ // Use folder theme for directories and symlinks to directories
+ theme := setting.UI.FileIconTheme
+ if entry.EntryMode.IsDir() || (entry.EntryMode.IsLink() && entry.SymlinkToMode.IsDir()) {
+ theme = setting.UI.FolderIconTheme
+ }
+
+ if theme == "material" {
return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry)
}
return BasicEntryIconHTML(entry)
diff --git a/modules/fileicon/render_test.go b/modules/fileicon/render_test.go
new file mode 100644
index 0000000000..d9998f3f4c
--- /dev/null
+++ b/modules/fileicon/render_test.go
@@ -0,0 +1,75 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package fileicon_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/fileicon"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRenderEntryIconHTML_WithDifferentThemes(t *testing.T) {
+ // Test that folder icons use the folder theme
+ t.Run("FolderUsesBasicTheme", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")()
+ defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")()
+
+ folderEntry := &fileicon.EntryInfo{
+ BaseName: "testfolder",
+ EntryMode: git.EntryModeTree,
+ }
+
+ html := fileicon.RenderEntryIconHTML(nil, folderEntry)
+ // Basic theme renders octicon classes
+ assert.Contains(t, string(html), "octicon-file-directory-fill")
+ })
+
+ t.Run("FileUsesMaterialTheme", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")()
+ defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")()
+
+ fileEntry := &fileicon.EntryInfo{
+ BaseName: "test.js",
+ EntryMode: git.EntryModeBlob,
+ }
+
+ html := fileicon.RenderEntryIconHTML(nil, fileEntry)
+ // Material theme for files renders material icons
+ assert.Contains(t, string(html), "svg-mfi-")
+ })
+
+ t.Run("SymlinkToFolderUsesBasicTheme", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")()
+ defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")()
+
+ symlinkEntry := &fileicon.EntryInfo{
+ BaseName: "link",
+ EntryMode: git.EntryModeSymlink,
+ SymlinkToMode: git.EntryModeTree,
+ }
+
+ html := fileicon.RenderEntryIconHTML(nil, symlinkEntry)
+ // Symlinks to folders should use folder theme
+ assert.Contains(t, string(html), "octicon-file-directory-symlink")
+ })
+
+ t.Run("BothMaterialTheme", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")()
+ defer test.MockVariableValue(&setting.UI.FolderIconTheme, "material")()
+
+ folderEntry := &fileicon.EntryInfo{
+ BaseName: "testfolder",
+ EntryMode: git.EntryModeTree,
+ }
+
+ html := fileicon.RenderEntryIconHTML(nil, folderEntry)
+ // Material theme for folders renders material folder icons
+ assert.Contains(t, string(html), "svg-mfi-")
+ })
+}
diff --git a/modules/setting/ui.go b/modules/setting/ui.go
index 13cb0f5c66..77a5b45d0a 100644
--- a/modules/setting/ui.go
+++ b/modules/setting/ui.go
@@ -29,6 +29,7 @@ var UI = struct {
DefaultTheme string
Themes []string
FileIconTheme string
+ FolderIconTheme string
Reactions []string
ReactionsLookup container.Set[string] `ini:"-"`
CustomEmojis []string
@@ -88,6 +89,7 @@ var UI = struct {
MaxDisplayFileSize: 8388608,
DefaultTheme: `gitea-auto`,
FileIconTheme: `material`,
+ FolderIconTheme: `basic`,
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:"},
diff --git a/services/repository/files/tree_test.go b/services/repository/files/tree_test.go
index e7511b3eed..b85f65f431 100644
--- a/services/repository/files/tree_test.go
+++ b/services/repository/files/tree_test.go
@@ -56,6 +56,7 @@ func TestGetTreeBySHA(t *testing.T) {
func TestGetTreeViewNodes(t *testing.T) {
unittest.PrepareTestEnv(t)
+
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.Repo.RefFullName = git.RefNameFromBranch("sub-home-md-img-check")
contexttest.LoadRepo(t, ctx, 1)
@@ -69,11 +70,13 @@ func TestGetTreeViewNodes(t *testing.T) {
mockIconForFile := func(id string) template.HTML {
return template.HTML(``)
}
- mockIconForFolder := func(id string) template.HTML {
- return template.HTML(``)
+ mockIconForFolder := func() template.HTML {
+ // With basic theme (default for folders), we get octicon icons without IDs
+ return template.HTML(`octicon-file-directory-fill(16/)`)
}
- mockOpenIconForFolder := func(id string) template.HTML {
- return template.HTML(``)
+ mockOpenIconForFolder := func() template.HTML {
+ // With basic theme (default for folders), we get octicon icons without IDs
+ return template.HTML(`octicon-file-directory-open-fill(16/)`)
}
treeNodes, err := GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "")
assert.NoError(t, err)
@@ -82,8 +85,8 @@ func TestGetTreeViewNodes(t *testing.T) {
EntryName: "docs",
EntryMode: "tree",
FullPath: "docs",
- EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`),
- EntryIconOpen: mockOpenIconForFolder(`svg-mfi-folder-docs`),
+ EntryIcon: mockIconForFolder(),
+ EntryIconOpen: mockOpenIconForFolder(),
},
}, treeNodes)
@@ -94,8 +97,8 @@ func TestGetTreeViewNodes(t *testing.T) {
EntryName: "docs",
EntryMode: "tree",
FullPath: "docs",
- EntryIcon: mockIconForFolder(`svg-mfi-folder-docs`),
- EntryIconOpen: mockOpenIconForFolder(`svg-mfi-folder-docs`),
+ EntryIcon: mockIconForFolder(),
+ EntryIconOpen: mockOpenIconForFolder(),
Children: []*TreeViewNode{
{
EntryName: "README.md",