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 <me@silverwind.io>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-03-30 16:59:10 +02:00
committed by GitHub
parent 539654831a
commit 612ce46cda
10 changed files with 160 additions and 75 deletions
+25
View File
@@ -9,7 +9,9 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strings"
"time" "time"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
@@ -61,6 +63,8 @@ type LayeredFS struct {
layers []*Layer layers []*Layer
} }
var _ fs.ReadDirFS = (*LayeredFS)(nil)
// Layered returns a new LayeredFS with the given layers. The first layer is the top layer. // Layered returns a new LayeredFS with the given layers. The first layer is the top layer.
func Layered(layers ...*Layer) *LayeredFS { func Layered(layers ...*Layer) *LayeredFS {
return &LayeredFS{layers: layers} return &LayeredFS{layers: layers}
@@ -83,6 +87,27 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) {
return bs, err 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. // ReadLayeredFile reads the named file, and returns the layer name.
func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) { func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) {
name := util.PathJoinRel(elems...) name := util.PathJoinRel(elems...)
+50 -17
View File
@@ -13,6 +13,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/routing" "code.gitea.io/gitea/modules/web/routing"
@@ -22,24 +23,29 @@ const viteDevPortFile = "public/assets/.vite/dev-port"
var viteDevProxy atomic.Pointer[httputil.ReverseProxy] 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 { func getViteDevProxy() *httputil.ReverseProxy {
if proxy := viteDevProxy.Load(); proxy != nil { if proxy := viteDevProxy.Load(); proxy != nil {
return proxy return proxy
} }
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) viteDevServerBaseURL := getViteDevServerBaseURL()
data, err := os.ReadFile(portFile) if viteDevServerBaseURL == "" {
if err != nil {
return nil
}
port := strings.TrimSpace(string(data))
if port == "" {
return nil return nil
} }
target, err := url.Parse("http://localhost:" + port) target, err := url.Parse(viteDevServerBaseURL)
if err != nil { 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 return nil
} }
@@ -60,7 +66,7 @@ func getViteDevProxy() *httputil.ReverseProxy {
ModifyResponse: func(resp *http.Response) error { ModifyResponse: func(resp *http.Response) error {
// add a header to indicate the Vite dev server port, // 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 // 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 return nil
}, },
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { 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. var viteDevModeCheck atomic.Pointer[struct {
// In production mode, the result is cached after the first check. isDev bool
func isViteDevMode() 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 { if setting.IsProd {
return false return false
} }
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
_, err := os.Stat(portFile) now := time.Now()
return err == nil 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 { func viteDevSourceURL(name string) string {
if !isViteDevMode() { if !IsViteDevMode() {
return "" return ""
} }
if strings.HasPrefix(name, "css/theme-") { if strings.HasPrefix(name, "css/theme-") {
+2
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"time" "time"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
) )
@@ -36,5 +37,6 @@ func CommonTemplateContextData() reqctx.ContextData {
"PageStartTime": time.Now(), "PageStartTime": time.Now(),
"RunModeIsProd": setting.IsProd, "RunModeIsProd": setting.IsProd,
"ViteModeIsDev": public.IsViteDevMode(),
} }
} }
+5
View File
@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/gtprof" "code.gitea.io/gitea/modules/gtprof"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/routing" "code.gitea.io/gitea/modules/web/routing"
@@ -40,6 +41,10 @@ func ProtocolMiddlewares() (handlers []any) {
handlers = append(handlers, context.AccessLogger()) handlers = append(handlers, context.AccessLogger())
} }
if !setting.IsProd {
handlers = append(handlers, public.ViteDevMiddleware)
}
return handlers return handlers
} }
-4
View File
@@ -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 // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
routes.BeforeRouting(chi_middleware.GetHead) 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.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, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
+63 -47
View File
@@ -4,10 +4,14 @@
package webtheme package webtheme
import ( import (
"io/fs"
"os"
"path"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"sync" "sync/atomic"
"time"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@@ -16,15 +20,15 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
type themeCollection struct { type themeCollectionStruct struct {
lastCheckTime time.Time
usingViteDevMode bool
themeList []*ThemeMetaInfo themeList []*ThemeMetaInfo
themeMap map[string]*ThemeMetaInfo themeMap map[string]*ThemeMetaInfo
} }
var ( var themeCollection atomic.Pointer[themeCollectionStruct]
themeMu sync.RWMutex
availableThemes *themeCollection
)
const ( const (
fileNamePrefix = "theme-" fileNamePrefix = "theme-"
@@ -140,23 +144,42 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
return themeInfo return themeInfo
} }
func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { func collectThemeFiles(dirFS fs.ReadDirFS, fsPath string) (themes []*ThemeMetaInfo, _ error) {
cssFiles, err := public.AssetFS().ListFiles("assets/css") files, err := dirFS.ReadDir(fsPath)
if err != nil { if err != nil {
log.Error("Failed to list themes: %v", err) return nil, err
return nil, nil }
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 foundThemes, err := collectThemeFiles(themeDir, themePath)
for _, fileName := range cssFiles { if err != nil {
if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) { log.Error("Failed to load theme files: %v", err)
content, err := public.AssetFS().ReadFile("/assets/css/" + fileName) return themeList, themeMap
if err != nil {
log.Error("Failed to read theme file %q: %v", fileName, err)
continue
}
foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
}
} }
themeList = foundThemes themeList = foundThemes
@@ -187,20 +210,21 @@ func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*Th
return themeList, themeMap return themeList, themeMap
} }
func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { func getAvailableThemes() *themeCollectionStruct {
themeMu.RLock() themes := themeCollection.Load()
if availableThemes != nil {
themeList, themeMap = availableThemes.themeList, availableThemes.themeMap now := time.Now()
} if themes != nil && now.Sub(themes.lastCheckTime) < time.Second {
themeMu.RUnlock() return themes
if len(themeList) != 0 {
return themeList, themeMap
} }
themeMu.Lock() isViteDevMode := public.IsViteDevMode()
defer themeMu.Unlock() useLoadedThemes := themes != nil && (setting.IsProd || themes.usingViteDevMode == isViteDevMode)
// no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple if useLoadedThemes && len(themes.themeList) > 0 {
themeList, themeMap = loadThemesFromAssets() return themes
}
themeList, themeMap := loadThemesFromAssets(isViteDevMode)
hasAvailableThemes := len(themeList) > 0 hasAvailableThemes := len(themeList) > 0
if !hasAvailableThemes { if !hasAvailableThemes {
defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme) defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)
@@ -215,27 +239,19 @@ func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*Them
if themeMap[setting.UI.DefaultTheme] == nil { 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) 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. themes = &themeCollectionStruct{now, isViteDevMode, themeList, themeMap}
// TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading. themeCollection.Store(themes)
// 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()
return themes return themes
} }
func GetAvailableThemes() []*ThemeMetaInfo {
return getAvailableThemes().themeList
}
func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { func GetThemeMetaInfo(internalName string) *ThemeMetaInfo {
_, themeMap := getAvailableThemes() return getAvailableThemes().themeMap[internalName]
return themeMap[internalName]
} }
// GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo, // GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,
+7 -2
View File
@@ -4,17 +4,22 @@
<a target="_blank" href="https://about.gitea.com">{{ctx.Locale.Tr "powered_by" "Gitea"}}</a> <a target="_blank" href="https://about.gitea.com">{{ctx.Locale.Tr "powered_by" "Gitea"}}</a>
{{end}} {{end}}
{{if (or .ShowFooterVersion .PageIsAdmin)}} {{if (or .ShowFooterVersion .PageIsAdmin)}}
<span>
{{ctx.Locale.Tr "version"}}: {{ctx.Locale.Tr "version"}}:
{{if .IsAdmin}} {{if .IsAdmin}}
<a href="{{AppSubUrl}}/-/admin/config">{{AppVer}}</a> <a href="{{AppSubUrl}}/-/admin/config">{{AppVer}}</a>
{{else}} {{else}}
{{AppVer}} {{AppVer}}
{{end}} {{end}}
</span>
{{end}} {{end}}
{{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}} {{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}}
{{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong> <span>
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong> {{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong>
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong>
</span>
{{end}} {{end}}
{{if $.ViteModeIsDev}}<span class="ui basic label primary">ViteDevMode</span>{{end}}
</div> </div>
<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}"> <div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}">
<div class="ui dropdown custom" id="footer-theme-selector"> <div class="ui dropdown custom" id="footer-theme-selector">
+6 -3
View File
@@ -1,8 +1,8 @@
import {build, defineConfig} from 'vite'; import {build, defineConfig} from 'vite';
import vuePlugin from '@vitejs/plugin-vue'; import vuePlugin from '@vitejs/plugin-vue';
import {stringPlugin} from 'vite-string-plugin'; 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 path, {join, parse} from 'node:path';
import {env} from 'node:process'; import {env} from 'node:process';
import tailwindcss from 'tailwindcss'; import tailwindcss from 'tailwindcss';
import tailwindConfig from './tailwind.config.ts'; import tailwindConfig from './tailwind.config.ts';
@@ -132,7 +132,9 @@ function iifePlugin(): Plugin {
server.middlewares.use((req, res, next) => { server.middlewares.use((req, res, next) => {
// "__vite_iife" is a virtual file in memory, serve it directly // "__vite_iife" is a virtual file in memory, serve it directly
const pathname = req.url!.split('?')[0]; const pathname = req.url!.split('?')[0];
if (pathname === '/web_src/js/__vite_iife.js') { if (pathname === '/web_src/js/__vite_dev_server_check') {
res.end('ok');
} else if (pathname === '/web_src/js/__vite_iife.js') {
res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.end(iifeCode); res.end(iifeCode);
@@ -198,6 +200,7 @@ function viteDevServerPortPlugin(): Plugin {
server.httpServer!.once('listening', () => { server.httpServer!.once('listening', () => {
const addr = server.httpServer!.address(); const addr = server.httpServer!.address();
if (typeof addr === 'object' && addr) { if (typeof addr === 'object' && addr) {
mkdirSync(path.dirname(viteDevPortFilePath), {recursive: true});
writeFileSync(viteDevPortFilePath, String(addr.port)); writeFileSync(viteDevPortFilePath, String(addr.port));
} }
}); });
+1 -1
View File
@@ -56,7 +56,7 @@
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.25em; gap: 0.5em;
} }
.page-footer .right-links { .page-footer .right-links {
+1 -1
View File
@@ -68,6 +68,7 @@
} }
#navbar .navbar-mobile-right { #navbar .navbar-mobile-right {
display: flex; display: flex;
align-items: center;
margin: 0 0 0 auto; margin: 0 0 0 auto;
width: auto; width: auto;
} }
@@ -97,7 +98,6 @@
} }
#navbar.navbar-menu-open .navbar-left .navbar-mobile-right { #navbar.navbar-menu-open .navbar-left .navbar-mobile-right {
justify-content: flex-end; justify-content: flex-end;
width: 50%;
min-height: 49px; min-height: 49px;
} }
#navbar #mobile-stopwatch-icon, #navbar #mobile-stopwatch-icon,