Fix theme loading in development (#36605)

Fixes: https://github.com/go-gitea/gitea/issues/36543

When running `make watch`, the backend may start before webpack finishes
building CSS theme files. Since themes were loaded once via sync.Once,
they would never reload, breaking the theme selector and showing a
persistent error on the admin page.

In dev mode, themes are now reloaded from disk on each access so they
become available as soon as webpack finishes. Production behavior is
unchanged where themes are loaded once and cached via sync.Once.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-02-17 22:46:42 +01:00
committed by GitHub
parent b970cc02c7
commit 63266ba036

View File

@@ -16,10 +16,14 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
type themeCollection struct {
themeList []*ThemeMetaInfo
themeMap map[string]*ThemeMetaInfo
}
var ( var (
availableThemes []*ThemeMetaInfo themeMu sync.RWMutex
availableThemeMap map[string]*ThemeMetaInfo availableThemes *themeCollection
themeOnce sync.Once
) )
const ( const (
@@ -129,23 +133,13 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
return themeInfo return themeInfo
} }
func initThemes() { func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
availableThemes = nil
defer func() {
availableThemeMap = map[string]*ThemeMetaInfo{}
for _, theme := range availableThemes {
availableThemeMap[theme.InternalName] = theme
}
if availableThemeMap[setting.UI.DefaultTheme] == nil {
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
}
}()
cssFiles, err := public.AssetFS().ListFiles("assets/css") cssFiles, err := public.AssetFS().ListFiles("assets/css")
if err != nil { if err != nil {
log.Error("Failed to list themes: %v", err) log.Error("Failed to list themes: %v", err)
availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)} return nil, nil
return
} }
var foundThemes []*ThemeMetaInfo var foundThemes []*ThemeMetaInfo
for _, fileName := range cssFiles { for _, fileName := range cssFiles {
if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) { if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
@@ -157,39 +151,84 @@ func initThemes() {
foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content))) foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
} }
} }
themeList = foundThemes
if len(setting.UI.Themes) > 0 { if len(setting.UI.Themes) > 0 {
themeList = nil // only allow the themes specified in the setting
allowedThemes := container.SetOf(setting.UI.Themes...) allowedThemes := container.SetOf(setting.UI.Themes...)
for _, theme := range foundThemes { for _, theme := range foundThemes {
if allowedThemes.Contains(theme.InternalName) { if allowedThemes.Contains(theme.InternalName) {
availableThemes = append(availableThemes, theme) themeList = append(themeList, theme)
} }
} }
} else {
availableThemes = foundThemes
} }
sort.Slice(availableThemes, func(i, j int) bool {
if availableThemes[i].InternalName == setting.UI.DefaultTheme { sort.Slice(themeList, func(i, j int) bool {
if themeList[i].InternalName == setting.UI.DefaultTheme {
return true return true
} }
if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType { if themeList[i].ColorblindType != themeList[j].ColorblindType {
return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType return themeList[i].ColorblindType < themeList[j].ColorblindType
} }
return availableThemes[i].DisplayName < availableThemes[j].DisplayName return themeList[i].DisplayName < themeList[j].DisplayName
}) })
if len(availableThemes) == 0 {
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme") themeMap = map[string]*ThemeMetaInfo{}
availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)} for _, theme := range themeList {
themeMap[theme.InternalName] = theme
} }
return themeList, themeMap
}
func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) {
themeMu.RLock()
if availableThemes != nil {
themeList, themeMap = availableThemes.themeList, availableThemes.themeMap
}
themeMu.RUnlock()
if len(themeList) != 0 {
return themeList, themeMap
}
themeMu.Lock()
defer themeMu.Unlock()
// no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple
themeList, themeMap = loadThemesFromAssets()
hasAvailableThemes := len(themeList) > 0
if !hasAvailableThemes {
defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)
themeList = []*ThemeMetaInfo{defaultTheme}
themeMap = map[string]*ThemeMetaInfo{setting.UI.DefaultTheme: defaultTheme}
}
if setting.IsProd {
if !hasAvailableThemes {
setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
}
if themeMap[setting.UI.DefaultTheme] == nil {
setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
}
availableThemes = &themeCollection{themeList, themeMap}
return themeList, themeMap
}
// In dev mode, only store the loaded themes if the list is not empty, in case the frontend is still being built.
// TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading.
// Such edge case can be handled by checking whether the loaded themes are the same in a period or there is a flag file, but it is an over-kill, so, no.
if hasAvailableThemes {
availableThemes = &themeCollection{themeList, themeMap}
}
return themeList, themeMap
} }
func GetAvailableThemes() []*ThemeMetaInfo { func GetAvailableThemes() []*ThemeMetaInfo {
themeOnce.Do(initThemes) themes, _ := getAvailableThemes()
return availableThemes return themes
} }
func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
themeOnce.Do(initThemes) _, themeMap := getAvailableThemes()
return availableThemeMap[internalName] return themeMap[internalName]
} }
// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo, // GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,