From c31e0cfc1c150abc9d1f361c16ca6b5ea0b7b97e Mon Sep 17 00:00:00 2001 From: Myers Carpenter Date: Mon, 30 Mar 2026 09:44:32 -0400 Subject: [PATCH 01/22] Expose content_version for optimistic locking on issue and PR edits (#37035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `content_version` field to Issue and PullRequest API responses - Accept optional `content_version` in `PATCH /repos/{owner}/{repo}/issues/{index}` and `PATCH /repos/{owner}/{repo}/pulls/{index}` — returns 409 Conflict when stale, succeeds silently when omitted (backward compatible) - Pre-check `content_version` before any mutations to prevent partial writes (e.g. title updated but body rejected) Co-authored-by: wxiaoguang --- modules/structs/issue.go | 4 +- modules/structs/pull.go | 4 +- routers/api/v1/repo/issue.go | 20 ++++++- routers/api/v1/repo/pull.go | 17 +++++- services/convert/issue.go | 3 +- services/convert/pull.go | 2 + templates/swagger/v1_json.tmpl | 21 +++++++ tests/integration/api_issue_test.go | 89 +++++++++++++++++++++++------ 8 files changed, 135 insertions(+), 25 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 1efe3334ca..a34e4b0693 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -80,7 +80,8 @@ type Issue struct { PullRequest *PullRequestMeta `json:"pull_request"` Repo *RepositoryMeta `json:"repository"` - PinOrder int `json:"pin_order"` + PinOrder int `json:"pin_order"` + ContentVersion int `json:"content_version"` } // CreateIssueOption options to create one issue @@ -114,6 +115,7 @@ type EditIssueOption struct { // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` RemoveDeadline *bool `json:"unset_due_date"` + ContentVersion *int `json:"content_version"` } // EditDeadlineOption options for creating a deadline diff --git a/modules/structs/pull.go b/modules/structs/pull.go index 3ad2f78bd3..ad320e2b82 100644 --- a/modules/structs/pull.go +++ b/modules/structs/pull.go @@ -90,7 +90,8 @@ type PullRequest struct { Closed *time.Time `json:"closed_at"` // The pin order for the pull request - PinOrder int `json:"pin_order"` + PinOrder int `json:"pin_order"` + ContentVersion int `json:"content_version"` } // PRBranchInfo information about a branch @@ -168,6 +169,7 @@ type EditPullRequestOption struct { RemoveDeadline *bool `json:"unset_due_date"` // Whether to allow maintainer edits AllowMaintainerEdit *bool `json:"allow_maintainer_edit"` + ContentVersion *int `json:"content_version"` } // ChangedFile store information about files affected by the pull request diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index db205380e4..20ccd099a4 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -726,6 +726,9 @@ func EditIssue(ctx *context.APIContext) { // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue // --- // summary: Edit an issue. If using deadline only the date will be taken into account, and time of day ignored. + // description: | + // Pass `content_version` to enable optimistic locking on body edits. + // If the version doesn't match the current value, the request fails with 409 Conflict. // consumes: // - application/json // produces: @@ -785,6 +788,15 @@ func EditIssue(ctx *context.APIContext) { return } + // Fail fast: if content_version is provided and already stale, reject + // before any mutations. The DB-level check in ChangeContent still + // handles concurrent requests. + // TODO: wrap all mutations in a transaction to fully prevent partial writes. + if form.ContentVersion != nil && *form.ContentVersion != issue.ContentVersion { + ctx.APIError(http.StatusConflict, issues_model.ErrIssueAlreadyChanged) + return + } + if len(form.Title) > 0 { err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) if err != nil { @@ -793,10 +805,14 @@ func EditIssue(ctx *context.APIContext) { } } if form.Body != nil { - err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion) + contentVersion := issue.ContentVersion + if form.ContentVersion != nil { + contentVersion = *form.ContentVersion + } + err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, contentVersion) if err != nil { if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { - ctx.APIError(http.StatusBadRequest, err) + ctx.APIError(http.StatusConflict, err) return } diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index a045bba49c..ef86f413b7 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -657,6 +657,15 @@ func EditPullRequest(ctx *context.APIContext) { return } + // Fail fast: if content_version is provided and already stale, reject + // before any mutations. The DB-level check in ChangeContent still + // handles concurrent requests. + // TODO: wrap all mutations in a transaction to fully prevent partial writes. + if form.ContentVersion != nil && *form.ContentVersion != issue.ContentVersion { + ctx.APIError(http.StatusConflict, issues_model.ErrIssueAlreadyChanged) + return + } + if len(form.Title) > 0 { err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) if err != nil { @@ -665,10 +674,14 @@ func EditPullRequest(ctx *context.APIContext) { } } if form.Body != nil { - err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion) + contentVersion := issue.ContentVersion + if form.ContentVersion != nil { + contentVersion = *form.ContentVersion + } + err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, contentVersion) if err != nil { if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { - ctx.APIError(http.StatusBadRequest, err) + ctx.APIError(http.StatusConflict, err) return } diff --git a/services/convert/issue.go b/services/convert/issue.go index acd67fece4..61f11d8f19 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -62,7 +62,8 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss Updated: issue.UpdatedUnix.AsTime(), PinOrder: util.Iif(issue.PinOrder == -1, 0, issue.PinOrder), // -1 means loaded with no pin order - TimeEstimate: issue.TimeEstimate, + TimeEstimate: issue.TimeEstimate, + ContentVersion: issue.ContentVersion, } if issue.Repo != nil { diff --git a/services/convert/pull.go b/services/convert/pull.go index bb675811f2..5c7c99f2ce 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -97,6 +97,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u Created: pr.Issue.CreatedUnix.AsTimePtr(), Updated: pr.Issue.UpdatedUnix.AsTimePtr(), PinOrder: util.Iif(apiIssue.PinOrder == -1, 0, apiIssue.PinOrder), + ContentVersion: apiIssue.ContentVersion, // output "[]" rather than null to align to github outputs RequestedReviewers: []*api.User{}, @@ -372,6 +373,7 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs Created: pr.Issue.CreatedUnix.AsTimePtr(), Updated: pr.Issue.UpdatedUnix.AsTimePtr(), PinOrder: util.Iif(apiIssue.PinOrder == -1, 0, apiIssue.PinOrder), + ContentVersion: apiIssue.ContentVersion, AllowMaintainerEdit: pr.AllowMaintainerEdit, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e01ff1112b..5ae0f197df 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -10362,6 +10362,7 @@ } }, "patch": { + "description": "Pass `content_version` to enable optimistic locking on body edits.\nIf the version doesn't match the current value, the request fails with 409 Conflict.\n", "consumes": [ "application/json" ], @@ -24766,6 +24767,11 @@ "type": "string", "x-go-name": "Body" }, + "content_version": { + "type": "integer", + "format": "int64", + "x-go-name": "ContentVersion" + }, "due_date": { "type": "string", "format": "date-time", @@ -24938,6 +24944,11 @@ "type": "string", "x-go-name": "Body" }, + "content_version": { + "type": "integer", + "format": "int64", + "x-go-name": "ContentVersion" + }, "due_date": { "type": "string", "format": "date-time", @@ -26223,6 +26234,11 @@ "format": "int64", "x-go-name": "Comments" }, + "content_version": { + "type": "integer", + "format": "int64", + "x-go-name": "ContentVersion" + }, "created_at": { "type": "string", "format": "date-time", @@ -27725,6 +27741,11 @@ "format": "int64", "x-go-name": "Comments" }, + "content_version": { + "type": "integer", + "format": "int64", + "x-go-name": "ContentVersion" + }, "created_at": { "type": "string", "format": "date-time", diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 8d85543dc8..c3e96059de 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -25,9 +25,19 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAPIListIssues(t *testing.T) { +func TestAPIIssue(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("ListIssues", testAPIListIssues) + t.Run("ListIssuesPublicOnly", testAPIListIssuesPublicOnly) + t.Run("SearchIssues", testAPISearchIssues) + t.Run("SearchIssuesWithLabels", testAPISearchIssuesWithLabels) + t.Run("EditIssue", testAPIEditIssue) + t.Run("IssueContentVersion", testAPIIssueContentVersion) + t.Run("CreateIssue", testAPICreateIssue) + t.Run("CreateIssueParallel", testAPICreateIssueParallel) +} +func testAPIListIssues(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -75,9 +85,7 @@ func TestAPIListIssues(t *testing.T) { } } -func TestAPIListIssuesPublicOnly(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIListIssuesPublicOnly(t *testing.T) { repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo1.OwnerID}) @@ -103,8 +111,7 @@ func TestAPIListIssuesPublicOnly(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) } -func TestAPICreateIssue(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAPICreateIssue(t *testing.T) { const body, title = "apiTestBody", "apiTestTitle" repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) @@ -142,9 +149,7 @@ func TestAPICreateIssue(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) } -func TestAPICreateIssueParallel(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPICreateIssueParallel(t *testing.T) { // FIXME: There seems to be a bug in github.com/mattn/go-sqlite3 with sqlite_unlock_notify, when doing concurrent writes to the same database, // some requests may get stuck in "go-sqlite3.(*SQLiteRows).Next", "go-sqlite3.(*SQLiteStmt).exec" and "go-sqlite3.unlock_notify_wait", // because the "unlock_notify_wait" never returns and the internal lock never gets releases. @@ -152,7 +157,7 @@ func TestAPICreateIssueParallel(t *testing.T) { // The trigger is: a previous test created issues and made the real issue indexer queue start processing, then this test does concurrent writing. // Adding this "Sleep" makes go-sqlite3 "finish" some internal operations before concurrent writes and then won't get stuck. // To reproduce: make a new test run these 2 tests enough times: - // > func TestBug() { for i := 0; i < 100; i++ { testAPICreateIssue(t); testAPICreateIssueParallel(t) } } + // > func testBug() { for i := 0; i < 100; i++ { testAPICreateIssue(t); testAPICreateIssueParallel(t) } } // Usually the test gets stuck in fewer than 10 iterations without this "sleep". time.Sleep(time.Second) @@ -197,9 +202,7 @@ func TestAPICreateIssueParallel(t *testing.T) { wg.Wait() } -func TestAPIEditIssue(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIEditIssue(t *testing.T) { issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) @@ -263,8 +266,7 @@ func TestAPIEditIssue(t *testing.T) { assert.Equal(t, title, issueAfter.Title) } -func TestAPISearchIssues(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAPISearchIssues(t *testing.T) { defer test.MockVariableValue(&setting.API.DefaultPagingNum, 20)() expectedIssueCount := 20 // 20 is from the fixtures @@ -391,9 +393,7 @@ func TestAPISearchIssues(t *testing.T) { assert.Len(t, apiIssues, 3) } -func TestAPISearchIssuesWithLabels(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPISearchIssuesWithLabels(t *testing.T) { // as this API was used in the frontend, it uses UI page size expectedIssueCount := min(20, setting.UI.IssuePagingNum) // 20 is from the fixtures @@ -448,3 +448,56 @@ func TestAPISearchIssuesWithLabels(t *testing.T) { DecodeJSON(t, resp, &apiIssues) assert.Len(t, apiIssues, 2) } + +func testAPIIssueContentVersion(t *testing.T) { + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repo.Name, issue.Index) + + t.Run("ResponseIncludesContentVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", urlStr).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + apiIssue := DecodeJSON(t, resp, &api.Issue{}) + assert.GreaterOrEqual(t, apiIssue.ContentVersion, 0) + }) + + t.Run("EditWithCorrectVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", urlStr).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var before api.Issue + DecodeJSON(t, resp, &before) + req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: new("updated body with correct version"), + ContentVersion: new(before.ContentVersion), + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + after := DecodeJSON(t, resp, &api.Issue{}) + assert.Equal(t, "updated body with correct version", after.Body) + assert.Greater(t, after.ContentVersion, before.ContentVersion) + }) + + t.Run("EditWithWrongVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: new("should fail"), + ContentVersion: new(99999), + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("EditWithoutVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: new("edit without version succeeds"), + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + }) +} From 539654831a1cfc4bab38d71810f25bdac4437164 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 30 Mar 2026 09:47:41 -0400 Subject: [PATCH 02/22] bump snapcraft deps (#37039) --- snap/snapcraft.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 4f3c4e9ff4..a1112def0c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -44,7 +44,7 @@ parts: source: . stage-packages: [ git, sqlite3, openssh-client ] build-packages: [ git, libpam0g-dev, libsqlite3-dev, build-essential] - build-snaps: [ go/1.25/stable, node/22/stable ] + build-snaps: [ go/1.26/stable, node/24/stable ] build-environment: - LDFLAGS: "" override-pull: | From 612ce46cda56dee5c922e5775bd3250474ab5b42 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 30 Mar 2026 16:59:10 +0200 Subject: [PATCH 03/22] 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}}