From 612ce46cda56dee5c922e5775bd3250474ab5b42 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 30 Mar 2026 16:59:10 +0200 Subject: [PATCH] Fix theme discovery and Vite dev server in dev mode (#37033) 1. In dev mode, discover themes from source files in `web_src/css/themes/` instead of AssetFS. In prod, use AssetFS only. Extract shared `collectThemeFiles` helper to deduplicate theme file handling. 2. Implement `fs.ReadDirFS` on `LayeredFS` to support theme file discovery. 3. `IsViteDevMode` now performs an HTTP health check against the vite dev server instead of only checking the port file exists. Result is cached with a 1-second TTL. 4. Refactor theme caching from mutex to atomic pointer with time-based invalidation, allowing themes to refresh when vite dev mode state changes. 5. Move `ViteDevMiddleware` into `ProtocolMiddlewares` so it applies to both install and web routes. 6. Show a `ViteDevMode` label in the page footer when vite dev server is active. 7. Add `/__vite_dev_server_check` endpoint to vite dev server for the health check. 8. Ensure `.vite` directory exists before writing the dev-port file. 9. Minor CSS fixes: footer gap, navbar mobile alignment. --- This PR was written with the help of Claude Opus 4.6 --------- Signed-off-by: silverwind Co-authored-by: Claude (Opus 4.6) Co-authored-by: wxiaoguang --- modules/assetfs/layered.go | 25 +++++++ modules/public/vitedev.go | 67 +++++++++++++----- modules/web/middleware/data.go | 2 + routers/common/middleware.go | 5 ++ routers/web/web.go | 4 -- services/webtheme/webtheme.go | 110 +++++++++++++++++------------ templates/base/footer_content.tmpl | 9 ++- vite.config.ts | 9 ++- web_src/css/home.css | 2 +- web_src/css/modules/navbar.css | 2 +- 10 files changed, 160 insertions(+), 75 deletions(-) diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go index 41e4ca7376..380c3ac455 100644 --- a/modules/assetfs/layered.go +++ b/modules/assetfs/layered.go @@ -9,7 +9,9 @@ import ( "io/fs" "os" "path/filepath" + "slices" "sort" + "strings" "time" "code.gitea.io/gitea/modules/container" @@ -61,6 +63,8 @@ type LayeredFS struct { layers []*Layer } +var _ fs.ReadDirFS = (*LayeredFS)(nil) + // Layered returns a new LayeredFS with the given layers. The first layer is the top layer. func Layered(layers ...*Layer) *LayeredFS { return &LayeredFS{layers: layers} @@ -83,6 +87,27 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) { return bs, err } +func (l *LayeredFS) ReadDir(name string) (files []fs.DirEntry, _ error) { + filesMap := map[string]fs.DirEntry{} + for _, layer := range l.layers { + entries, err := readDirOptional(layer, name) + if err != nil { + return nil, err + } + for _, entry := range entries { + entryName := entry.Name() + if _, exist := filesMap[entryName]; !exist && shouldInclude(entry) { + filesMap[entryName] = entry + } + } + } + for _, file := range filesMap { + files = append(files, file) + } + slices.SortFunc(files, func(a, b fs.DirEntry) int { return strings.Compare(a.Name(), b.Name()) }) + return files, nil +} + // ReadLayeredFile reads the named file, and returns the layer name. func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) { name := util.PathJoinRel(elems...) diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 9c8da951fc..25bd28a826 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "time" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/routing" @@ -22,24 +23,29 @@ const viteDevPortFile = "public/assets/.vite/dev-port" var viteDevProxy atomic.Pointer[httputil.ReverseProxy] +func getViteDevServerBaseURL() string { + portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) + portContent, _ := os.ReadFile(portFile) + port := strings.TrimSpace(string(portContent)) + if port == "" { + return "" + } + return "http://localhost:" + port +} + func getViteDevProxy() *httputil.ReverseProxy { if proxy := viteDevProxy.Load(); proxy != nil { return proxy } - portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) - data, err := os.ReadFile(portFile) - if err != nil { - return nil - } - port := strings.TrimSpace(string(data)) - if port == "" { + viteDevServerBaseURL := getViteDevServerBaseURL() + if viteDevServerBaseURL == "" { return nil } - target, err := url.Parse("http://localhost:" + port) + target, err := url.Parse(viteDevServerBaseURL) if err != nil { - log.Error("Failed to parse Vite dev server URL: %v", err) + log.Error("Failed to parse Vite dev server base URL %s, err: %v", viteDevServerBaseURL, err) return nil } @@ -60,7 +66,7 @@ func getViteDevProxy() *httputil.ReverseProxy { ModifyResponse: func(resp *http.Response) error { // add a header to indicate the Vite dev server port, // make developers know that this request is proxied to Vite dev server and which port it is - resp.Header.Add("X-Gitea-Vite-Port", port) + resp.Header.Add("X-Gitea-Vite-Dev-Server", viteDevServerBaseURL) return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { @@ -92,19 +98,46 @@ func ViteDevMiddleware(next http.Handler) http.Handler { }) } -// isViteDevMode returns true if the Vite dev server port file exists. -// In production mode, the result is cached after the first check. -func isViteDevMode() bool { +var viteDevModeCheck atomic.Pointer[struct { + isDev bool + time time.Time +}] + +// IsViteDevMode returns true if the Vite dev server port file exists and the server is alive +func IsViteDevMode() bool { if setting.IsProd { return false } - portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) - _, err := os.Stat(portFile) - return err == nil + + now := time.Now() + lastCheck := viteDevModeCheck.Load() + if lastCheck != nil && time.Now().Sub(lastCheck.time) < time.Second { + return lastCheck.isDev + } + + viteDevServerBaseURL := getViteDevServerBaseURL() + if viteDevServerBaseURL == "" { + return false + } + + req := httplib.NewRequest(viteDevServerBaseURL+"/web_src/js/__vite_dev_server_check", "GET") + resp, _ := req.Response() + if resp != nil { + _ = resp.Body.Close() + } + isDev := resp != nil && resp.StatusCode == http.StatusOK + viteDevModeCheck.Store(&struct { + isDev bool + time time.Time + }{ + isDev: isDev, + time: now, + }) + return isDev } func viteDevSourceURL(name string) string { - if !isViteDevMode() { + if !IsViteDevMode() { return "" } if strings.HasPrefix(name, "css/theme-") { diff --git a/modules/web/middleware/data.go b/modules/web/middleware/data.go index 41fb1e7e6f..7d9e816042 100644 --- a/modules/web/middleware/data.go +++ b/modules/web/middleware/data.go @@ -7,6 +7,7 @@ import ( "context" "time" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" ) @@ -36,5 +37,6 @@ func CommonTemplateContextData() reqctx.ContextData { "PageStartTime": time.Now(), "RunModeIsProd": setting.IsProd, + "ViteModeIsDev": public.IsViteDevMode(), } } diff --git a/routers/common/middleware.go b/routers/common/middleware.go index 9daffb04f1..39911e2548 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/gtprof" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/routing" @@ -40,6 +41,10 @@ func ProtocolMiddlewares() (handlers []any) { handlers = append(handlers, context.AccessLogger()) } + if !setting.IsProd { + handlers = append(handlers, public.ViteDevMiddleware) + } + return handlers } diff --git a/routers/web/web.go b/routers/web/web.go index 72d2c27eaf..e3dcf27cc4 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -259,10 +259,6 @@ func Routes() *web.Router { // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route routes.BeforeRouting(chi_middleware.GetHead) - if !setting.IsProd { - routes.BeforeRouting(public.ViteDevMiddleware) - } - routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc()) routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 2f3d06d780..f8322381ca 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -4,10 +4,14 @@ package webtheme import ( + "io/fs" + "os" + "path" "regexp" "sort" "strings" - "sync" + "sync/atomic" + "time" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" @@ -16,15 +20,15 @@ import ( "code.gitea.io/gitea/modules/util" ) -type themeCollection struct { +type themeCollectionStruct struct { + lastCheckTime time.Time + usingViteDevMode bool + themeList []*ThemeMetaInfo themeMap map[string]*ThemeMetaInfo } -var ( - themeMu sync.RWMutex - availableThemes *themeCollection -) +var themeCollection atomic.Pointer[themeCollectionStruct] const ( fileNamePrefix = "theme-" @@ -140,23 +144,42 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { return themeInfo } -func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { - cssFiles, err := public.AssetFS().ListFiles("assets/css") +func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaInfo, _ error) { + files, err := dirFS.ReadDir(fsPath) if err != nil { - log.Error("Failed to list themes: %v", err) - return nil, nil + return nil, err + } + for _, file := range files { + fileName := file.Name() + if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { + continue + } + content, err := fs.ReadFile(dirFS, path.Join(fsPath, file.Name())) + if err != nil { + log.Error("Failed to read theme file %q: %v", fileName, err) + continue + } + themes = append(themes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content))) + } + return themes, nil +} + +func loadThemesFromAssets(isViteDevMode bool) (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { + var themeDir fs.ReadDirFS + var themePath string + + if isViteDevMode { + // In vite dev mode, Vite serves themes directly from source files. + themeDir, themePath = os.DirFS(setting.StaticRootPath).(fs.ReadDirFS), "web_src/css/themes" + } else { + // Without vite dev server, use built assets from AssetFS. + themeDir, themePath = public.AssetFS(), "assets/css" } - var foundThemes []*ThemeMetaInfo - for _, fileName := range cssFiles { - if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) { - content, err := public.AssetFS().ReadFile("/assets/css/" + fileName) - if err != nil { - log.Error("Failed to read theme file %q: %v", fileName, err) - continue - } - foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content))) - } + foundThemes, err := collectThemeFiles(themeDir, themePath) + if err != nil { + log.Error("Failed to load theme files: %v", err) + return themeList, themeMap } themeList = foundThemes @@ -187,20 +210,21 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th 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 +func getAvailableThemes() *themeCollectionStruct { + themes := themeCollection.Load() + + now := time.Now() + if themes != nil && now.Sub(themes.lastCheckTime) < time.Second { + return themes } - 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() + isViteDevMode := public.IsViteDevMode() + useLoadedThemes := themes != nil && (setting.IsProd || themes.usingViteDevMode == isViteDevMode) + if useLoadedThemes && len(themes.themeList) > 0 { + return themes + } + + themeList, themeMap := loadThemesFromAssets(isViteDevMode) hasAvailableThemes := len(themeList) > 0 if !hasAvailableThemes { defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme) @@ -215,27 +239,19 @@ func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*Them 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 { - themes, _ := getAvailableThemes() + themes = &themeCollectionStruct{now, isViteDevMode, themeList, themeMap} + themeCollection.Store(themes) return themes } +func GetAvailableThemes() []*ThemeMetaInfo { + return getAvailableThemes().themeList +} + func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { - _, themeMap := getAvailableThemes() - return themeMap[internalName] + return getAvailableThemes().themeMap[internalName] } // GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo, diff --git a/templates/base/footer_content.tmpl b/templates/base/footer_content.tmpl index 66c9d718ea..3b0af6ddc3 100644 --- a/templates/base/footer_content.tmpl +++ b/templates/base/footer_content.tmpl @@ -4,17 +4,22 @@ {{ctx.Locale.Tr "powered_by" "Gitea"}} {{end}} {{if (or .ShowFooterVersion .PageIsAdmin)}} + {{ctx.Locale.Tr "version"}}: {{if .IsAdmin}} {{AppVer}} {{else}} {{AppVer}} {{end}} + {{end}} {{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}} - {{ctx.Locale.Tr "page"}}: {{LoadTimes .PageStartTime}} - {{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: {{call .TemplateLoadTimes}} + + {{ctx.Locale.Tr "page"}}: {{LoadTimes .PageStartTime}} + {{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: {{call .TemplateLoadTimes}} + {{end}} + {{if $.ViteModeIsDev}}ViteDevMode{{end}}