From b0e8a941cfb6c12f65971e3d71508e51798e6efd Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 29 Mar 2026 20:49:12 +0200 Subject: [PATCH 01/13] Fix theme discovery and Vite dev server in dev mode 1. In dev mode, discover themes from source files in web_src/css/themes/ instead of relying on built assets. Custom themes are still discovered via AssetFS. 2. Move ViteDevMiddleware into ProtocolMiddlewares so it applies to both install and web routes. 3. Ensure .vite directory exists before writing the dev-port file. Co-Authored-By: Claude (Opus 4.6) --- routers/common/middleware.go | 5 ++++ routers/web/web.go | 4 --- services/webtheme/webtheme.go | 50 +++++++++++++++++++++++++++++------ vite.config.ts | 3 ++- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/routers/common/middleware.go b/routers/common/middleware.go index 9daffb04f1c03..39911e254810d 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 72d2c27eafdb4..e3dcf27cc4afe 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 2f3d06d78067e..a5942902871b6 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -4,6 +4,8 @@ package webtheme import ( + "os" + "path/filepath" "regexp" "sort" "strings" @@ -141,21 +143,53 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { } func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { - cssFiles, err := public.AssetFS().ListFiles("assets/css") - if err != nil { - log.Error("Failed to list themes: %v", err) - return nil, nil + var foundThemes []*ThemeMetaInfo + seenNames := make(container.Set[string]) + + // In dev mode, use source theme files directly instead of relying on built assets. + if !setting.IsProd { + srcDir := filepath.Join(setting.StaticRootPath, "web_src/css/themes") + if entries, err := os.ReadDir(srcDir); err == nil { + for _, entry := range entries { + fileName := entry.Name() + if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { + continue + } + content, err := os.ReadFile(filepath.Join(srcDir, fileName)) + if err != nil { + log.Error("Failed to read theme source file %q: %v", fileName, err) + continue + } + themeInfo := parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)) + foundThemes = append(foundThemes, themeInfo) + seenNames.Add(themeInfo.InternalName) + } + } } - var foundThemes []*ThemeMetaInfo - for _, fileName := range cssFiles { - if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) { + // Check custom assets + cssFiles, err := public.AssetFS().ListFiles("assets/css") + if err != nil { + if len(foundThemes) == 0 { + log.Error("Failed to list themes: %v", err) + return nil, nil + } + } else { + for _, fileName := range cssFiles { + if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { + continue + } 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))) + themeInfo := parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)) + if seenNames.Contains(themeInfo.InternalName) { + continue + } + foundThemes = append(foundThemes, themeInfo) + seenNames.Add(themeInfo.InternalName) } } diff --git a/vite.config.ts b/vite.config.ts index d2c7abac054c6..557b32470fcd8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import {build, defineConfig} from 'vite'; import vuePlugin from '@vitejs/plugin-vue'; import {stringPlugin} from 'vite-string-plugin'; -import {readFileSync, writeFileSync, unlinkSync, globSync} from 'node:fs'; +import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs'; import {join, parse} from 'node:path'; import {env} from 'node:process'; import tailwindcss from 'tailwindcss'; @@ -198,6 +198,7 @@ function viteDevServerPortPlugin(): Plugin { server.httpServer!.once('listening', () => { const addr = server.httpServer!.address(); if (typeof addr === 'object' && addr) { + mkdirSync(join(outDir, '.vite'), {recursive: true}); writeFileSync(viteDevPortFilePath, String(addr.port)); } }); From 59833df4bd69f46b0d1845ce238c656eda5abaf5 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 30 Mar 2026 10:41:38 +0200 Subject: [PATCH 02/13] Refactor theme discovery to use separate dev/prod paths Extract collectThemeFiles helper to deduplicate theme file handling. In dev mode, only read source files (Vite serves them directly). In prod mode, only read from AssetFS. Remove seenNames dedup logic since the two sources no longer overlap. Log warning on ReadDir failure. Co-Authored-By: Claude (Opus 4.6) --- services/webtheme/webtheme.go | 70 ++++++++++++++++------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index a5942902871b6..1fb98ae58629a 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -142,55 +142,49 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { return themeInfo } +func collectThemeFiles(fileNames []string, readFile func(string) ([]byte, error)) []*ThemeMetaInfo { + var themes []*ThemeMetaInfo + for _, fileName := range fileNames { + if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { + continue + } + content, err := readFile(fileName) + 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 +} + func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { var foundThemes []*ThemeMetaInfo - seenNames := make(container.Set[string]) - // In dev mode, use source theme files directly instead of relying on built assets. if !setting.IsProd { + // In dev mode, Vite serves themes directly from source files. srcDir := filepath.Join(setting.StaticRootPath, "web_src/css/themes") - if entries, err := os.ReadDir(srcDir); err == nil { + entries, err := os.ReadDir(srcDir) + if err != nil { + log.Warn("Failed to read theme source directory %q: %v", srcDir, err) + } else { + fileNames := make([]string, 0, len(entries)) for _, entry := range entries { - fileName := entry.Name() - if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { - continue - } - content, err := os.ReadFile(filepath.Join(srcDir, fileName)) - if err != nil { - log.Error("Failed to read theme source file %q: %v", fileName, err) - continue - } - themeInfo := parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)) - foundThemes = append(foundThemes, themeInfo) - seenNames.Add(themeInfo.InternalName) + fileNames = append(fileNames, entry.Name()) } + foundThemes = collectThemeFiles(fileNames, func(name string) ([]byte, error) { + return os.ReadFile(filepath.Join(srcDir, name)) + }) } - } - - // Check custom assets - cssFiles, err := public.AssetFS().ListFiles("assets/css") - if err != nil { - if len(foundThemes) == 0 { + } else { + cssFiles, err := public.AssetFS().ListFiles("assets/css") + if err != nil { log.Error("Failed to list themes: %v", err) return nil, nil } - } else { - for _, fileName := range cssFiles { - if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { - continue - } - content, err := public.AssetFS().ReadFile("/assets/css/" + fileName) - if err != nil { - log.Error("Failed to read theme file %q: %v", fileName, err) - continue - } - themeInfo := parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)) - if seenNames.Contains(themeInfo.InternalName) { - continue - } - foundThemes = append(foundThemes, themeInfo) - seenNames.Add(themeInfo.InternalName) - } + foundThemes = collectThemeFiles(cssFiles, func(name string) ([]byte, error) { + return public.AssetFS().ReadFile("/assets/css/" + name) + }) } themeList = foundThemes From 823241417b47174f5f1eb73db7daa6a59a914e94 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 30 Mar 2026 10:47:38 +0200 Subject: [PATCH 03/13] Add comment to production branch in theme discovery Co-Authored-By: Claude (Opus 4.6) --- services/webtheme/webtheme.go | 1 + 1 file changed, 1 insertion(+) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 1fb98ae58629a..639b11e388213 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -177,6 +177,7 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th }) } } else { + // In production, use built assets from AssetFS. cssFiles, err := public.AssetFS().ListFiles("assets/css") if err != nil { log.Error("Failed to list themes: %v", err) From de3093269eb92d11083cd3960eaffd2fe5d56614 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 30 Mar 2026 10:48:06 +0200 Subject: [PATCH 04/13] Apply suggestion from @silverwind Signed-off-by: silverwind --- services/webtheme/webtheme.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 639b11e388213..e73a4169d8929 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -177,7 +177,7 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th }) } } else { - // In production, use built assets from AssetFS. + // In prod mode, use built assets from AssetFS. cssFiles, err := public.AssetFS().ListFiles("assets/css") if err != nil { log.Error("Failed to list themes: %v", err) From a546bd3128c5d8855da20d8689c7c64972ed7547 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 19:08:24 +0800 Subject: [PATCH 05/13] refactor --- modules/assetfs/layered.go | 24 +++++++++++++++++ services/webtheme/webtheme.go | 49 +++++++++++++++-------------------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go index 41e4ca7376de9..63c55053d85cf 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,26 @@ 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 { + if shouldInclude(entry) { + filesMap[entry.Name()] = 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/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index e73a4169d8929..23874d6e331b6 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -4,8 +4,9 @@ package webtheme import ( + "io/fs" "os" - "path/filepath" + "path" "regexp" "sort" "strings" @@ -142,50 +143,42 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { return themeInfo } -func collectThemeFiles(fileNames []string, readFile func(string) ([]byte, error)) []*ThemeMetaInfo { - var themes []*ThemeMetaInfo - for _, fileName := range fileNames { +func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaInfo, _ error) { + files, err := dirFS.ReadDir(fsPath) + if err != nil { + return nil, err + } + for _, file := range files { + fileName := file.Name() if !strings.HasPrefix(fileName, fileNamePrefix) || !strings.HasSuffix(fileName, fileNameSuffix) { continue } - content, err := readFile(fileName) + 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 + return themes, nil } func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { - var foundThemes []*ThemeMetaInfo + var themeDir fs.ReadDirFS + var themePath string if !setting.IsProd { // In dev mode, Vite serves themes directly from source files. - srcDir := filepath.Join(setting.StaticRootPath, "web_src/css/themes") - entries, err := os.ReadDir(srcDir) - if err != nil { - log.Warn("Failed to read theme source directory %q: %v", srcDir, err) - } else { - fileNames := make([]string, 0, len(entries)) - for _, entry := range entries { - fileNames = append(fileNames, entry.Name()) - } - foundThemes = collectThemeFiles(fileNames, func(name string) ([]byte, error) { - return os.ReadFile(filepath.Join(srcDir, name)) - }) - } + themeDir, themePath = os.DirFS(setting.StaticRootPath).(fs.ReadDirFS), "web_src/css/themes" } else { // In prod mode, use built assets from AssetFS. - cssFiles, err := public.AssetFS().ListFiles("assets/css") - if err != nil { - log.Error("Failed to list themes: %v", err) - return nil, nil - } - foundThemes = collectThemeFiles(cssFiles, func(name string) ([]byte, error) { - return public.AssetFS().ReadFile("/assets/css/" + name) - }) + themeDir, themePath = public.AssetFS(), "assets/css" + } + + foundThemes, err := collectThemeFiles(themeDir, themePath) + if err != nil { + log.Error("Failed to load theme files: %v", err) + return } themeList = foundThemes From d042d19ba6497b390afb2965cb5b43d7eb941f46 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 19:23:29 +0800 Subject: [PATCH 06/13] fix IsViteDevMode --- modules/public/vitedev.go | 24 ++++++++++-------------- services/webtheme/webtheme.go | 4 ++-- vite.config.ts | 9 ++++++--- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 9c8da951fc159..a9e48cdec2213 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -28,18 +28,15 @@ func getViteDevProxy() *httputil.ReverseProxy { } portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) - data, err := os.ReadFile(portFile) + portContent, err := os.ReadFile(portFile) if err != nil { return nil } - port := strings.TrimSpace(string(data)) - if port == "" { - return nil - } + viteDevServerPort := strings.TrimSpace(string(portContent)) - target, err := url.Parse("http://localhost:" + port) + target, err := url.Parse("http://localhost:" + viteDevServerPort) if err != nil { - log.Error("Failed to parse Vite dev server URL: %v", err) + log.Error("Failed to use dev port (%s) to construct URL: %v", viteDevServerPort, err) return nil } @@ -60,7 +57,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-Port", viteDevServerPort) return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { @@ -92,19 +89,18 @@ 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 { +// IsViteDevMode returns true if the Vite dev server port file exists and is alive +func IsViteDevMode() bool { if setting.IsProd { return false } portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) - _, err := os.Stat(portFile) - return err == nil + stat, err := os.Stat(portFile) + return err == nil && time.Now().Sub(stat.ModTime()) < 10*time.Second } func viteDevSourceURL(name string) string { - if !isViteDevMode() { + if !IsViteDevMode() { return "" } if strings.HasPrefix(name, "css/theme-") { diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 23874d6e331b6..494b0c4bcc393 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -167,8 +167,8 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th var themeDir fs.ReadDirFS var themePath string - if !setting.IsProd { - // In dev mode, Vite serves themes directly from source files. + if public.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 { // In prod mode, use built assets from AssetFS. diff --git a/vite.config.ts b/vite.config.ts index 557b32470fcd8..b5e4ffd29b123 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,15 @@ import {build, defineConfig} from 'vite'; import vuePlugin from '@vitejs/plugin-vue'; import {stringPlugin} from 'vite-string-plugin'; -import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs'; -import {join, parse} from 'node:path'; +import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync, utimesSync} from 'node:fs'; +import path, {join, parse} from 'node:path'; import {env} from 'node:process'; import tailwindcss from 'tailwindcss'; import tailwindConfig from './tailwind.config.ts'; import wrapAnsi from 'wrap-ansi'; import licensePlugin from 'rollup-plugin-license'; import type {InlineConfig, Plugin, Rolldown} from 'vite'; +import {setInterval} from "node:timers"; const isProduction = env.NODE_ENV !== 'development'; @@ -198,8 +199,10 @@ function viteDevServerPortPlugin(): Plugin { server.httpServer!.once('listening', () => { const addr = server.httpServer!.address(); if (typeof addr === 'object' && addr) { - mkdirSync(join(outDir, '.vite'), {recursive: true}); + mkdirSync(path.dirname(viteDevPortFilePath), {recursive: true}); writeFileSync(viteDevPortFilePath, String(addr.port)); + // keep updating the timestamp of the file to tell the Gitea vite dev proxy that the server is still alive + setInterval(() => utimesSync(viteDevPortFilePath, new Date(), new Date()), 2000); } }); }, From ae684e896c744883d42797e9d0819f263043c5e6 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 19:33:53 +0800 Subject: [PATCH 07/13] clean up vite dev server --- vite.config.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index b5e4ffd29b123..b1aba965f6052 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import {build, defineConfig} from 'vite'; import vuePlugin from '@vitejs/plugin-vue'; import {stringPlugin} from 'vite-string-plugin'; -import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync, utimesSync} from 'node:fs'; +import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync, utimesSync, rmSync} from 'node:fs'; import path, {join, parse} from 'node:path'; import {env} from 'node:process'; import tailwindcss from 'tailwindcss'; @@ -201,8 +201,25 @@ function viteDevServerPortPlugin(): Plugin { if (typeof addr === 'object' && addr) { mkdirSync(path.dirname(viteDevPortFilePath), {recursive: true}); writeFileSync(viteDevPortFilePath, String(addr.port)); + + let cleanedUp = false; // keep updating the timestamp of the file to tell the Gitea vite dev proxy that the server is still alive - setInterval(() => utimesSync(viteDevPortFilePath, new Date(), new Date()), 2000); + setInterval(() => { + if (cleanedUp) return; + utimesSync(viteDevPortFilePath, new Date(), new Date()); + }, 2000); + + // clean up the port file on exit to prevent stale port info for the next dev server or interfere the Gitea vite dev proxy + const viteDevServerCleanUp = () => { + if (cleanedUp) return; + console.info('cleaning up vite dev server...'); + rmSync(viteDevPortFilePath); + server.close(); + cleanedUp = true; + process.exit(0); + }; + process.on('SIGINT', viteDevServerCleanUp); + process.on('SIGTERM', viteDevServerCleanUp); } }); }, From 7abfeaa9ee62865927f6247f0a6e0d1aa9042407 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 30 Mar 2026 13:44:19 +0200 Subject: [PATCH 08/13] fix lint and fmt --- services/webtheme/webtheme.go | 2 +- vite.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 494b0c4bcc393..8088f5635ad04 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -178,7 +178,7 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th foundThemes, err := collectThemeFiles(themeDir, themePath) if err != nil { log.Error("Failed to load theme files: %v", err) - return + return themeList, themeMap } themeList = foundThemes diff --git a/vite.config.ts b/vite.config.ts index b1aba965f6052..f3fafd5ae280e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ import tailwindConfig from './tailwind.config.ts'; import wrapAnsi from 'wrap-ansi'; import licensePlugin from 'rollup-plugin-license'; import type {InlineConfig, Plugin, Rolldown} from 'vite'; -import {setInterval} from "node:timers"; +import {setInterval} from 'node:timers'; const isProduction = env.NODE_ENV !== 'development'; From 0a573a0e86c61ce78270fdd08a81c5ded4b929c2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 19:49:49 +0800 Subject: [PATCH 09/13] fix theme loading --- services/webtheme/webtheme.go | 58 ++++++++++++++++------------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 8088f5635ad04..8ce22f2160838 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -10,7 +10,8 @@ import ( "regexp" "sort" "strings" - "sync" + "sync/atomic" + "time" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" @@ -19,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-" @@ -163,11 +164,11 @@ func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaIn return themes, nil } -func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { +func loadThemesFromAssets(isViteDevMode bool) (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { var themeDir fs.ReadDirFS var themePath string - if public.IsViteDevMode() { + 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 { @@ -209,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 +func getAvailableThemes() *themeCollectionStruct { + themes := themeCollection.Load() + + now := time.Now() + if themes != nil && now.Sub(themes.lastCheckTime) < time.Second { + return themes } - themeMu.RUnlock() - if len(themeList) != 0 { - return themeList, themeMap + + isViteDevMode := public.IsViteDevMode() + useLoadedThemes := themes != nil && (setting.IsProd || themes.usingViteDevMode == isViteDevMode) + if useLoadedThemes && len(themes.themeList) > 0 { + 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() + themeList, themeMap := loadThemesFromAssets(isViteDevMode) hasAvailableThemes := len(themeList) > 0 if !hasAvailableThemes { defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme) @@ -237,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 + themes = &themeCollectionStruct{now, isViteDevMode, themeList, themeMap} + themeCollection.Store(themes) + return themes } func GetAvailableThemes() []*ThemeMetaInfo { - themes, _ := getAvailableThemes() - return themes + 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, From 9b5a511555c81a61b8103ae6b2a860e07d4cdb87 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 20:00:52 +0800 Subject: [PATCH 10/13] avoid unnecessary file system access --- modules/public/vitedev.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index a9e48cdec2213..324edea4602d5 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -89,14 +89,33 @@ func ViteDevMiddleware(next http.Handler) http.Handler { }) } +var viteDevModeCheck atomic.Pointer[struct { + isDev bool + time time.Time +}] + // IsViteDevMode returns true if the Vite dev server port file exists and is alive func IsViteDevMode() bool { if setting.IsProd { return false } + + now := time.Now() + lastCheck := viteDevModeCheck.Load() + if lastCheck != nil && time.Now().Sub(lastCheck.time) < time.Second { + return lastCheck.isDev + } portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) stat, err := os.Stat(portFile) - return err == nil && time.Now().Sub(stat.ModTime()) < 10*time.Second + isDev := err == nil && time.Now().Sub(stat.ModTime()) < 10*time.Second + viteDevModeCheck.Store(&struct { + isDev bool + time time.Time + }{ + isDev: isDev, + time: now, + }) + return isDev } func viteDevSourceURL(name string) string { From f50501df71c3f18f377e48d1f9e3c1e10dc67948 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 20:13:05 +0800 Subject: [PATCH 11/13] add a label to indicate vite dev mode --- modules/web/middleware/data.go | 2 ++ templates/base/head_navbar.tmpl | 2 ++ web_src/css/modules/navbar.css | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/web/middleware/data.go b/modules/web/middleware/data.go index 41fb1e7e6f64d..7d9e8160428bf 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/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 447f78565e0c3..c486245b10e7d 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -7,6 +7,7 @@ @@ -42,6 +43,7 @@