mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-05 14:48:07 +00:00
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:
@@ -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...)
|
||||
|
||||
@@ -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-") {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user