From 17b802beae1928049319f7ea02b5fa9db6ec0b85 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 28 Mar 2026 08:59:52 +0100 Subject: [PATCH 01/39] Clean up checkbox cursor styles (#37016) 1. Remove non-functional `label:enabled` selector (`:enabled` only works on [form controls](https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled), not labels) 2. Remove `cursor: auto` which caused an I-beam text selection cursor on checkbox labels. The default browser styles work find and show regular cursor. 3. Remove `cursor: pointer` on checkbox itself, opinionated and not needed. Co-authored-by: Claude (Opus 4.6) --- web_src/css/modules/checkbox.css | 7 ------- 1 file changed, 7 deletions(-) diff --git a/web_src/css/modules/checkbox.css b/web_src/css/modules/checkbox.css index 220abfc17d2..f24b91df07b 100644 --- a/web_src/css/modules/checkbox.css +++ b/web_src/css/modules/checkbox.css @@ -91,14 +91,7 @@ input[type="checkbox"]:indeterminate::before { height: var(--checkbox-size); } -.ui.checkbox input[type="checkbox"]:enabled, -.ui.checkbox input[type="radio"]:enabled, -.ui.checkbox label:enabled { - cursor: pointer; -} - .ui.checkbox label { - cursor: auto; position: relative; display: block; } From 896e4838cbb367b0874be773a489a5a304f0c8d0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 28 Mar 2026 10:05:56 +0100 Subject: [PATCH 02/39] Update message severity colors, fix navbar double border (#37019) - Tweak serverity background and border colors - Use default text color instead of per-severity text colors. - Replace `saturate` filter with semibold font weight on message headers. - Fix navbar double border when a notification is present. Co-authored-by: Claude (Opus 4.6) --- web_src/css/modules/message.css | 2 +- web_src/css/modules/navbar.css | 5 +++++ web_src/css/themes/theme-gitea-dark.css | 28 ++++++++++++------------ web_src/css/themes/theme-gitea-light.css | 28 ++++++++++++------------ 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/web_src/css/modules/message.css b/web_src/css/modules/message.css index ce997c4350b..d5346616bce 100644 --- a/web_src/css/modules/message.css +++ b/web_src/css/modules/message.css @@ -43,7 +43,7 @@ .ui.message .header { color: inherit; - filter: saturate(2); + font-weight: var(--font-weight-semibold); } .ui.info.message, diff --git a/web_src/css/modules/navbar.css b/web_src/css/modules/navbar.css index 19a9f389d74..7a55f80fee2 100644 --- a/web_src/css/modules/navbar.css +++ b/web_src/css/modules/navbar.css @@ -7,6 +7,11 @@ padding: 0 10px; } +/* When notification message is present after navbar, hide border to avoid double border */ +#navbar:has(+ .ui.message) { + border-bottom: none; +} + #navbar .navbar-left, #navbar .navbar-right { display: flex; diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index fbdef1e2fb8..610e5f1344a 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -162,20 +162,20 @@ gitea-theme-meta-info { --color-diff-removed-row-border: #634343; --color-diff-removed-word-bg: #6f3333; --color-diff-inactive: #22282d; - --color-error-border: #da3633; - --color-error-bg: #3c2425; - --color-error-bg-active: #5a3637; - --color-error-bg-hover: #4c2d2e; - --color-error-text: #f5817c; - --color-success-border: #458a57; - --color-success-bg: #284034; - --color-success-text: #69be61; - --color-warning-border: #9e6a03; - --color-warning-bg: #2f2a1b; - --color-warning-text: #d29922; - --color-info-border: #306090; - --color-info-bg: #26354c; - --color-info-text: #48b7f8; + --color-error-border: #763232; + --color-error-bg: #322226; + --color-error-bg-active: #49262a; + --color-error-bg-hover: #3c2427; + --color-error-text: var(--color-text); + --color-success-border: #225633; + --color-success-bg: #1c3329; + --color-success-text: var(--color-text); + --color-warning-border: #5f481a; + --color-warning-bg: #342e1f; + --color-warning-text: var(--color-text); + --color-info-border: #254a7e; + --color-info-bg: #1b283a; + --color-info-text: var(--color-text); --color-red-badge: #db2828; --color-red-badge-bg: #db28281a; --color-red-badge-hover-bg: #db28284d; diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 761cb18da05..0885c5618b3 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -162,20 +162,20 @@ gitea-theme-meta-info { --color-diff-removed-row-border: #f1c0c0; --color-diff-removed-word-bg: #fdb8c0; --color-diff-inactive: #f0f2f4; - --color-error-border: #d63333; - --color-error-bg: #ffebeb; - --color-error-bg-active: #fdd; - --color-error-bg-hover: #fee; - --color-error-text: #8a3231; - --color-success-border: #49842b; - --color-success-bg: #eef6e4; - --color-success-text: #2f6e30; - --color-warning-border: #bf8700; - --color-warning-bg: #fff8e1; - --color-warning-text: #744500; - --color-info-border: #2d8fa8; - --color-info-bg: #e8f4fd; - --color-info-text: #216078; + --color-error-border: #ff818266; + --color-error-bg: #ffebe9; + --color-error-bg-active: #ffcecb; + --color-error-bg-hover: #ffdcd7; + --color-error-text: var(--color-text); + --color-success-border: #4ac26b66; + --color-success-bg: #dafbe1; + --color-success-text: var(--color-text); + --color-warning-border: #d4a72c66; + --color-warning-bg: #fff8c5; + --color-warning-text: var(--color-text); + --color-info-border: #54aeff66; + --color-info-bg: #ddf4ff; + --color-info-text: var(--color-text); --color-red-badge: #db2828; --color-red-badge-bg: #db28281a; --color-red-badge-hover-bg: #db28284d; From b136a66d123a2a7d456775aeccc50086a3432ea2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 28 Mar 2026 10:41:34 +0100 Subject: [PATCH 03/39] Restyle Workflow Graph (#36912) Follow GitHub's style and fine tune colors & layouts. Co-authored-by: Claude Sonnet 4.6 Co-authored-by: wxiaoguang Co-authored-by: silverwind --- routers/web/devtest/mock_actions.go | 27 +- routers/web/repo/actions/view.go | 2 + web_src/css/base.css | 4 +- web_src/css/themes/theme-gitea-dark.css | 2 +- web_src/css/themes/theme-gitea-light.css | 2 +- .../js/components/ActionRunSummaryView.vue | 39 +- web_src/js/components/ActionRunView.ts | 1 + web_src/js/components/RepoActionView.vue | 13 +- web_src/js/components/WorkflowGraph.vue | 777 +++++++----------- web_src/js/features/repo-actions.ts | 6 +- web_src/js/modules/gitea-actions.ts | 1 + 11 files changed, 385 insertions(+), 489 deletions(-) diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 00ca095e716..0fb2a358243 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -68,6 +68,7 @@ func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") resp := &actions.ViewResponse{} + resp.State.Run.RepoID = 12345 resp.State.Run.TitleHTML = `mock run title link` resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10) resp.State.Run.Status = actions_model.StatusRunning.String() @@ -135,12 +136,36 @@ func MockActionsRunsJobs(ctx *context.Context) { resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 2, JobID: "job-102", - Name: "job 102", + Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit", Status: actions_model.StatusFailure.String(), CanRerun: false, Duration: "3h", Needs: []string{"job-100", "job-101"}, }) + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID*10 + 3, + JobID: "job-103", + Name: "job 103", + Status: actions_model.StatusCancelled.String(), + CanRerun: false, + Duration: "2m", + Needs: []string{"job-100"}, + }) + + // add more jobs to a run for UI testing + if resp.State.Run.CanCancel { + for i := range 10 { + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID*1000 + int64(i), + JobID: "job-dup-test-" + strconv.Itoa(i), + Name: "job dup test " + strconv.Itoa(i), + Status: actions_model.StatusSuccess.String(), + CanRerun: false, + Duration: "2m", + Needs: []string{"job-103", "job-101", "job-100"}, + }) + } + } fillViewRunResponseCurrentJob(ctx, resp) ctx.JSON(http.StatusOK, resp) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 90810a6d251..6b3e95f3daf 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -129,6 +129,7 @@ type ViewResponse struct { State struct { Run struct { + RepoID int64 `json:"repoId"` Link string `json:"link"` Title string `json:"title"` TitleHTML template.HTML `json:"titleHTML"` @@ -252,6 +253,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, return } + resp.State.Run.RepoID = ctx.Repo.Repository.ID // the title for the "run" is from the commit message resp.State.Run.Title = run.Title resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository) diff --git a/web_src/css/base.css b/web_src/css/base.css index b4139c0e728..60317887bad 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -808,9 +808,7 @@ table th[data-sortt-desc] .svg { .btn, .ui.ui.dropdown, -.flex-text-inline, -.flex-text-inline > a, -.flex-text-inline > span { +.flex-text-inline { display: inline-flex; align-items: center; gap: var(--gap-inline); diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 610e5f1344a..28dd8784815 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -208,7 +208,6 @@ gitea-theme-meta-info { --color-input-toggle-background: #2e353c; --color-input-border: var(--color-secondary-dark-1); --color-light: #00001728; - --color-light-mimic-enabled: rgba(0, 0, 0, calc(40 / 255 * 222 / 255 / var(--opacity-disabled))); --color-light-border: #e8f3ff28; --color-hover: #e8f3ff19; --color-hover-opaque: #21252a; /* TODO: color-mix(in srgb, var(--color-body), var(--color-hover)); */ @@ -249,6 +248,7 @@ gitea-theme-meta-info { --color-danger: var(--color-red); --color-transparency-grid-light: #2a2a2a; --color-transparency-grid-dark: #1a1a1a; + --color-workflow-edge-hover: #616e78; accent-color: var(--color-accent); color-scheme: dark; } diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 0885c5618b3..6576b88987f 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -208,7 +208,6 @@ gitea-theme-meta-info { --color-input-toggle-background: #d0d7de; --color-input-border: var(--color-secondary-dark-1); --color-light: #00001706; - --color-light-mimic-enabled: rgba(0, 0, 0, calc(6 / 255 * 222 / 255 / var(--opacity-disabled))); --color-light-border: #0000171d; --color-hover: #00001708; --color-hover-opaque: #f1f3f5; /* TODO: color-mix(in srgb, var(--color-body), var(--color-hover)); */ @@ -249,6 +248,7 @@ gitea-theme-meta-info { --color-danger: var(--color-red); --color-transparency-grid-light: #fafafa; --color-transparency-grid-dark: #e2e2e2; + --color-workflow-edge-hover: #b1b7bd; accent-color: var(--color-accent); color-scheme: light; } diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index 2d79a82288e..48af966c94b 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -29,35 +29,42 @@ onBeforeUnmount(() => { }); diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 250f39e811d..133b7263eba 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -89,6 +89,7 @@ export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) export function createEmptyActionsRun(): ActionsRun { return { + repoId: 0, link: '', title: '', titleHTML: '', diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 4ced86b523b..3637763b90e 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -222,7 +222,11 @@ async function deleteArtifact(name: string) { max-width: 400px; position: sticky; top: 12px; - max-height: 100vh; + + /* about 12px top padding + 12px bottom padding + 37px footer height, + TODO: need to use JS to calculate the height for better scrolling experience*/ + max-height: calc(100vh - 62px); + overflow-y: auto; background: var(--color-body); z-index: 2; /* above .job-info-header */ @@ -231,12 +235,13 @@ async function deleteArtifact(name: string) { @media (max-width: 767.98px) { .action-view-left { position: static; /* can not sticky because multiple jobs would overlap into right view */ + max-height: unset; } } .left-list-header { - font-size: 12px; - color: var(--color-grey); + font-size: 13px; + color: var(--color-text-light-2); } .job-artifacts-item { @@ -299,7 +304,6 @@ async function deleteArtifact(name: string) { .job-brief-item .job-brief-item-left .job-brief-name { display: block; - width: 70%; } .job-brief-item .job-brief-item-right { @@ -320,7 +324,6 @@ async function deleteArtifact(name: string) { border: 1px solid var(--color-console-border); border-radius: var(--border-radius); background: var(--color-console-bg); - align-self: flex-start; } /* begin fomantic button overrides */ diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index c311b87d980..06ac1686e68 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -1,31 +1,31 @@ + `, - setting.StaticURLPrefix, - setting.AssetVersion, + public.AssetURI("css/swagger.css"), html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(util.UnsafeBytesToString(content)), - setting.StaticURLPrefix, - setting.AssetVersion, + public.AssetURI("js/swagger.js"), )) return err } diff --git a/modules/markup/render.go b/modules/markup/render.go index 5785dc5ad54..c0d44c72fcc 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup/internal" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" @@ -237,10 +238,10 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, return renderIFrame(ctx, extOpts.ContentSandbox, output) } // else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS - extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css" - extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js" + extraStyleHref := public.AssetURI("css/external-render-iframe.css") + extraScriptSrc := public.AssetURI("js/external-render-iframe.js") // "`, extraScriptSrc, extraStyleHref) + extraHeadHTML = htmlutil.HTMLFormat(``, extraScriptSrc, extraStyleHref) } ctx.usedByRender = true diff --git a/modules/public/manifest.go b/modules/public/manifest.go new file mode 100644 index 00000000000..77e89599672 --- /dev/null +++ b/modules/public/manifest.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package public + +import ( + "io" + "path" + "sync" + "sync/atomic" + "time" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +type manifestEntry struct { + File string `json:"file"` + Name string `json:"name"` + IsEntry bool `json:"isEntry"` + CSS []string `json:"css"` +} + +type manifestDataStruct struct { + paths map[string]string // unhashed path -> hashed path + names map[string]string // hashed path -> entry name + modTime int64 + checkTime time.Time +} + +var ( + manifestData atomic.Pointer[manifestDataStruct] + manifestFS = sync.OnceValue(AssetFS) +) + +const manifestPath = "assets/.vite/manifest.json" + +func parseManifest(data []byte) (map[string]string, map[string]string) { + var manifest map[string]manifestEntry + if err := json.Unmarshal(data, &manifest); err != nil { + log.Error("Failed to parse frontend manifest: %v", err) + return nil, nil + } + + paths := make(map[string]string) + names := make(map[string]string) + for _, entry := range manifest { + if !entry.IsEntry || entry.Name == "" { + continue + } + // Build unhashed key from file path: "js/index.js", "css/theme-gitea-dark.css" + dir := path.Dir(entry.File) + ext := path.Ext(entry.File) + key := dir + "/" + entry.Name + ext + paths[key] = entry.File + names[entry.File] = entry.Name + // Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css" + for _, css := range entry.CSS { + cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css) + paths[cssKey] = css + names[css] = entry.Name + } + } + return paths, names +} + +func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct { + now := time.Now() + data := existingData + if data != nil && now.Sub(data.checkTime) < time.Second { + // a single request triggers multiple calls to getHashedPath + // do not check the manifest file too frequently + return data + } + + f, err := manifestFS().Open(manifestPath) + if err != nil { + log.Error("Failed to open frontend manifest: %v", err) + return data + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + log.Error("Failed to stat frontend manifest: %v", err) + return data + } + + needReload := data == nil || fi.ModTime().UnixNano() != data.modTime + if !needReload { + return data + } + manifestContent, err := io.ReadAll(f) + if err != nil { + log.Error("Failed to read frontend manifest: %v", err) + return data + } + return storeManifestFromBytes(manifestContent, fi.ModTime().UnixNano(), now) +} + +func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct { + paths, names := parseManifest(manifestContent) + data := &manifestDataStruct{ + paths: paths, + names: names, + modTime: modTime, + checkTime: checkTime, + } + manifestData.Store(data) + return data +} + +func getManifestData() *manifestDataStruct { + data := manifestData.Load() + + // In production the manifest is immutable (embedded in the binary). + // In dev mode, check if it changed on disk (for watch-frontend). + if data == nil || !setting.IsProd { + data = reloadManifest(data) + } + if data == nil { + data = &manifestDataStruct{} + } + return data +} + +// getHashedPath resolves an unhashed asset path (origin path) to its content-hashed path from the frontend manifest. +// Example: getHashedPath("js/index.js") returns "js/index.C6Z2MRVQ.js" +// Falls back to returning the input path unchanged if the manifest is unavailable. +func getHashedPath(originPath string) string { + data := getManifestData() + if p, ok := data.paths[originPath]; ok { + return p + } + return originPath +} + +// AssetURI returns the URI for a frontend asset. +// It may return a relative path or a full URL depending on the StaticURLPrefix setting. +// In Vite dev mode, known entry points are mapped to their source paths +// so the reverse proxy serves them from the Vite dev server. +// In production, it resolves the content-hashed path from the manifest. +func AssetURI(originPath string) string { + if src := viteDevSourceURL(originPath); src != "" { + return src + } + return setting.StaticURLPrefix + "/assets/" + getHashedPath(originPath) +} + +// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path. +// Example: returns "theme-gitea-dark" for "css/theme-gitea-dark.CyAaQnn5.css". +// Returns empty string if the path is not found in the manifest. +func AssetNameFromHashedPath(hashedPath string) string { + return getManifestData().names[hashedPath] +} diff --git a/modules/public/manifest_test.go b/modules/public/manifest_test.go new file mode 100644 index 00000000000..20a2232cf38 --- /dev/null +++ b/modules/public/manifest_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package public + +import ( + "testing" + "time" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestViteManifest(t *testing.T) { + defer test.MockVariableValue(&setting.IsProd, true)() + + const testManifest = `{ + "web_src/js/index.ts": { + "file": "js/index.C6Z2MRVQ.js", + "name": "index", + "src": "web_src/js/index.ts", + "isEntry": true, + "css": ["css/index.B3zrQPqD.css"] + }, + "web_src/js/standalone/swagger.ts": { + "file": "js/swagger.SujiEmYM.js", + "name": "swagger", + "src": "web_src/js/standalone/swagger.ts", + "isEntry": true, + "css": ["css/swagger._-APWT_3.css"] + }, + "web_src/css/themes/theme-gitea-dark.css": { + "file": "css/theme-gitea-dark.CyAaQnn5.css", + "name": "theme-gitea-dark", + "src": "web_src/css/themes/theme-gitea-dark.css", + "isEntry": true + }, + "web_src/js/features/eventsource.sharedworker.ts": { + "file": "js/eventsource.sharedworker.Dug1twio.js", + "name": "eventsource.sharedworker", + "src": "web_src/js/features/eventsource.sharedworker.ts", + "isEntry": true + }, + "_chunk.js": { + "file": "js/chunk.abc123.js", + "name": "chunk" + } +}` + + t.Run("EmptyManifest", func(t *testing.T) { + storeManifestFromBytes([]byte(``), 0, time.Now()) + assert.Equal(t, "/assets/js/index.js", AssetURI("js/index.js")) + assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("css/theme-gitea-dark.css")) + assert.Equal(t, "", AssetNameFromHashedPath("css/no-such-file.css")) + }) + + t.Run("ParseManifest", func(t *testing.T) { + storeManifestFromBytes([]byte(testManifest), 0, time.Now()) + paths, names := manifestData.Load().paths, manifestData.Load().names + + // JS entries + assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"]) + assert.Equal(t, "js/swagger.SujiEmYM.js", paths["js/swagger.js"]) + assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"]) + + // Associated CSS from JS entries + assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"]) + assert.Equal(t, "css/swagger._-APWT_3.css", paths["css/swagger.css"]) + + // CSS-only entries + assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"]) + + // Non-entry chunks should not be included + assert.Empty(t, paths["js/chunk.js"]) + + // Names: hashed path -> entry name + assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"]) + assert.Equal(t, "index", names["css/index.B3zrQPqD.css"]) + assert.Equal(t, "swagger", names["js/swagger.SujiEmYM.js"]) + assert.Equal(t, "swagger", names["css/swagger._-APWT_3.css"]) + assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"]) + assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"]) + + // Test Asset related functions + assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("js/index.js")) + assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("css/theme-gitea-dark.css")) + assert.Equal(t, "theme-gitea-dark", AssetNameFromHashedPath("css/theme-gitea-dark.CyAaQnn5.css")) + }) +} diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go new file mode 100644 index 00000000000..9c8da951fc1 --- /dev/null +++ b/modules/public/vitedev.go @@ -0,0 +1,168 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package public + +import ( + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/routing" +) + +const viteDevPortFile = "public/assets/.vite/dev-port" + +var viteDevProxy atomic.Pointer[httputil.ReverseProxy] + +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 == "" { + return nil + } + + target, err := url.Parse("http://localhost:" + port) + if err != nil { + log.Error("Failed to parse Vite dev server URL: %v", err) + return nil + } + + // there is a strange error log (from Golang's HTTP package) + // 2026/03/28 19:50:13 modules/log/misc.go:72:(*loggerToWriter).Write() [I] Unsolicited response received on idle HTTP channel starting with "HTTP/1.1 400 Bad Request\r\n\r\n"; err= + // maybe it is caused by that the Vite dev server doesn't support keep-alive connections? or different keep-alive timeouts? + transport := &http.Transport{ + IdleConnTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + } + log.Info("Proxying Vite dev server requests to %s", target) + proxy := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(target) + r.Out.Host = target.Host + }, + 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) + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Error("Error proxying to Vite dev server: %v", err) + http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway) + }, + } + viteDevProxy.Store(proxy) + return proxy +} + +// ViteDevMiddleware proxies matching requests to the Vite dev server. +// It is registered as middleware in non-production mode and lazily discovers +// the Vite dev server port from the port file written by the viteDevServerPortPlugin. +// It is needed because there are container-based development, only Gitea web server's port is exposed. +func ViteDevMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + if !isViteDevRequest(req) { + next.ServeHTTP(resp, req) + return + } + proxy := getViteDevProxy() + if proxy == nil { + next.ServeHTTP(resp, req) + return + } + routing.MarkLongPolling(resp, req) + proxy.ServeHTTP(resp, req) + }) +} + +// 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 { + if setting.IsProd { + return false + } + portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile) + _, err := os.Stat(portFile) + return err == nil +} + +func viteDevSourceURL(name string) string { + if !isViteDevMode() { + return "" + } + if strings.HasPrefix(name, "css/theme-") { + // Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/ + themeFile := strings.TrimPrefix(name, "css/") + srcPath := filepath.Join(setting.StaticRootPath, "web_src/css/themes", themeFile) + if _, err := os.Stat(srcPath); err == nil { + return setting.AppSubURL + "/web_src/css/themes/" + themeFile + } + return "" + } + if strings.HasPrefix(name, "css/") { + return setting.AppSubURL + "/web_src/" + name + } + if name == "js/eventsource.sharedworker.js" { + return setting.AppSubURL + "/web_src/js/features/eventsource.sharedworker.ts" + } + if name == "js/iife.js" { + return setting.AppSubURL + "/web_src/js/__vite_iife.js" + } + if name == "js/index.js" { + return setting.AppSubURL + "/web_src/js/index.ts" + } + return "" +} + +// isViteDevRequest returns true if the request should be proxied to the Vite dev server. +// Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts +func isViteDevRequest(req *http.Request) bool { + if req.Header.Get("Upgrade") == "websocket" { + wsProtocol := req.Header.Get("Sec-WebSocket-Protocol") + return wsProtocol == "vite-hmr" || wsProtocol == "vite-ping" + } + path := req.URL.Path + + // vite internal requests + if strings.HasPrefix(path, "/@vite/") /* HMR client */ || + strings.HasPrefix(path, "/@fs/") /* out-of-root file access, see vite.config.ts: fs.allow */ || + strings.HasPrefix(path, "/@id/") /* virtual modules */ { + return true + } + + // local source requests (VITE-DEV-SERVER-SECURITY: don't serve sensitive files outside the allowed paths) + if strings.HasPrefix(path, "/node_modules/") || + strings.HasPrefix(path, "/public/assets/") || + strings.HasPrefix(path, "/web_src/") { + return true + } + + // Vite uses a path relative to project root and adds "?import" to non-JS/CSS asset imports: + // - {WebSite}/public/assets/... (e.g. SVG icons from "{RepoRoot}/public/assets/img/svg/") + // - {WebSite}/assets/emoji.json: it is an exception for the frontend assets, it is imported by JS code, but: + // - KEEP IN MIND: all static frontend assets are served from "{AssetFS}/assets" to "{WebSite}/assets" by Gitea Web Server + // - "{AssetFS}" is a layered filesystem from "{RepoRoot}/public" or embedded assets, and user's custom files in "{CustomPath}/public" + // - "{RepoRoot}/assets/emoji.json" just happens to have the dir name "assets", it is not related to frontend assets + // - BAD DESIGN: indeed it is a "conflicted and polluted name" sample + if path == "/assets/emoji.json" { + return true + } + return false +} diff --git a/modules/setting/server.go b/modules/setting/server.go index f0fbbce970a..1085e052a3e 100644 --- a/modules/setting/server.go +++ b/modules/setting/server.go @@ -72,9 +72,6 @@ var ( // It maps to ini:"LOCAL_ROOT_URL" in [server] LocalURL string - // AssetVersion holds an opaque value that is used for cache-busting assets - AssetVersion string - // appTempPathInternal is the temporary path for the app, it is only an internal variable // DO NOT use it directly, always use AppDataTempDir appTempPathInternal string @@ -317,8 +314,6 @@ func loadServerFrom(rootCfg ConfigProvider) { } AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix) - AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed) - manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL) ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes) diff --git a/modules/templates/helper.go b/modules/templates/helper.go index d2d4d364df0..3a5eb5904f7 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -6,15 +6,18 @@ package templates import ( "fmt" + "html" "html/template" "net/url" "strconv" "strings" + "sync" "time" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/templates/eval" @@ -68,6 +71,8 @@ func NewFuncMap() template.FuncMap { return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms" }, + "AssetURI": public.AssetURI, + "ScriptImport": scriptImport, // ----------------------------------------------------------------- // setting "AppName": func() string { @@ -92,9 +97,6 @@ func NewFuncMap() template.FuncMap { "AppDomain": func() string { // documented in mail-templates.md return setting.Domain }, - "AssetVersion": func() string { - return setting.AssetVersion - }, "ShowFooterTemplateLoadTime": func() bool { return setting.Other.ShowFooterTemplateLoadTime }, @@ -303,3 +305,30 @@ func QueryBuild(a ...any) template.URL { } return template.URL(s) } + +var globalVars = sync.OnceValue(func() (ret struct { + scriptImportRemainingPart string +}, +) { + // add onerror handler to alert users when the script fails to load: + // * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config + // * for developers: help them to remember to run "make watch-frontend" to build frontend assets + // the message will be directly put in the onerror JS code's string + onScriptErrorPrompt := `Please make sure the asset files can be accessed.` + if !setting.IsProd { + onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.` + } + onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt) + ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `">` + return ret +}) + +func scriptImport(path string, typ ...string) template.HTML { + if len(typ) > 0 { + if typ[0] == "module" { + return template.HTML(` - +{{ScriptImport "js/iife.js"}} diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl index b2fc033558c..15fa7ad730c 100644 --- a/templates/base/head_style.tmpl +++ b/templates/base/head_style.tmpl @@ -1,2 +1,2 @@ - - + + diff --git a/templates/devtest/devtest-footer.tmpl b/templates/devtest/devtest-footer.tmpl index a1b3b86e5c4..868136e1948 100644 --- a/templates/devtest/devtest-footer.tmpl +++ b/templates/devtest/devtest-footer.tmpl @@ -1,3 +1,3 @@ {{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}} - + {{template "base/footer" ctx.RootData}} diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl index 0775dccc2d7..a7aebcb7dc8 100644 --- a/templates/devtest/devtest-header.tmpl +++ b/templates/devtest/devtest-header.tmpl @@ -1,3 +1,8 @@ {{template "base/head" ctx.RootData}} - + + {{template "base/alert" .}} diff --git a/templates/status/500.tmpl b/templates/status/500.tmpl index 424f590f84e..c230fadb169 100644 --- a/templates/status/500.tmpl +++ b/templates/status/500.tmpl @@ -1,5 +1,5 @@ {{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics. -* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl +* base template functions: AppName, AssetUrlPrefix, AssetURI, AppSubUrl * ctx.Locale * .Flash * .ErrorMsg diff --git a/templates/swagger/ui.tmpl b/templates/swagger/ui.tmpl index 4ff34728071..d53a6111764 100644 --- a/templates/swagger/ui.tmpl +++ b/templates/swagger/ui.tmpl @@ -2,13 +2,13 @@ Gitea API - + {{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}} {{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}
- + diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 691ffcc62b5..3d9d7b39696 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/external" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" @@ -107,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) { // default sandbox in sub page response assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "
<script></script>
`, respSub.Body.String()) + assert.Equal(t, `
<script></script>
`, respSub.Body.String()) }) }) @@ -130,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) { t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") respSub := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, ``, respSub.Body.String()) + assert.Equal(t, ``, respSub.Body.String()) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) }) diff --git a/tsconfig.json b/tsconfig.json index 9b978cf54ea..851bf13dc9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,7 +45,7 @@ "verbatimModuleSyntax": true, "types": [ "node", - "webpack/module", + "vite/client", "vitest/globals", "./web_src/js/globals.d.ts", "./types.d.ts", diff --git a/types.d.ts b/types.d.ts index 59d6ecf149f..234bd267fe2 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,8 +1,3 @@ -declare module '@techknowlogick/license-checker-webpack-plugin' { - const plugin: any; - export = plugin; -} - declare module 'eslint-plugin-no-use-extend-native' { import type {Eslint} from 'eslint'; const plugin: Eslint.Plugin; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000000..d2c7abac054 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,332 @@ +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 {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'; + +const isProduction = env.NODE_ENV !== 'development'; + +// ENABLE_SOURCEMAP accepts the following values: +// true - all sourcemaps enabled, the default in development +// reduced - sourcemaps only for index.js, the default in production +// false - all sourcemaps disabled +let enableSourcemap: string; +if ('ENABLE_SOURCEMAP' in env) { + enableSourcemap = ['true', 'false'].includes(env.ENABLE_SOURCEMAP!) ? env.ENABLE_SOURCEMAP! : 'reduced'; +} else { + enableSourcemap = isProduction ? 'reduced' : 'true'; +} +const outDir = join(import.meta.dirname, 'public/assets'); + +const themes: Record = {}; +for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) { + themes[parse(path).name] = join(import.meta.dirname, path); +} + +const webComponents = new Set([ + // our own, in web_src/js/webcomponents + 'overflow-menu', + 'origin-url', + 'relative-time', + // from dependencies + 'markdown-toolbar', + 'text-expander', +]); + +function formatLicenseText(licenseText: string) { + return wrapAnsi(licenseText || '', 80).trim(); +} + +const commonRolldownOptions: Rolldown.RolldownOptions = { + checks: { + eval: false, // htmx needs eval + pluginTimings: false, + }, +}; + +function commonViteOpts({build, ...other}: InlineConfig): InlineConfig { + const {rolldownOptions, ...otherBuild} = build || {}; + return { + base: './', // make all asset URLs relative, so it works in subdirectory deployments + configFile: false, + root: import.meta.dirname, + publicDir: false, + build: { + outDir, + emptyOutDir: false, + sourcemap: enableSourcemap !== 'false', + target: 'es2020', + minify: isProduction ? 'oxc' : false, + cssMinify: isProduction ? 'esbuild' : false, + chunkSizeWarningLimit: Infinity, + assetsInlineLimit: 32768, + reportCompressedSize: false, + rolldownOptions: { + ...commonRolldownOptions, + ...rolldownOptions, + }, + ...otherBuild, + }, + ...other, + }; +} + +const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts'); + +function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) { + return commonViteOpts({ + build: { + lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'}, + rolldownOptions: {output: {entryFileNames}}, + ...(write === false && {write: false}), + }, + plugins: [stringPlugin()], + }); +} + +// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory +// and rebuilds on file changes. In prod mode, writes to disk during closeBundle. +function iifePlugin(): Plugin { + let iifeCode = ''; + let iifeMap = ''; + const iifeModules = new Set(); + let isBuilding = false; + return { + name: 'iife', + async configureServer(server) { + const buildAndCache = async () => { + const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false})); + const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput; + const chunk = output.output[0]; + iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map'); + const mapAsset = output.output.find((o) => o.fileName.endsWith('.map')); + iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : ''; + iifeModules.clear(); + for (const id of Object.keys(chunk.modules)) iifeModules.add(id); + }; + await buildAndCache(); + + let needsRebuild = false; + server.watcher.on('change', async (path) => { + if (!iifeModules.has(path)) return; + needsRebuild = true; + if (isBuilding) return; + isBuilding = true; + try { + do { + needsRebuild = false; + await buildAndCache(); + } while (needsRebuild); + server.ws.send({type: 'full-reload'}); + } finally { + isBuilding = false; + } + }); + + server.middlewares.use((req, res, next) => { + // "__vite_iife" is a virtual file in memory, serve it directly + const pathname = req.url!.split('?')[0]; + if (pathname === '/web_src/js/__vite_iife.js') { + res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('Cache-Control', 'no-store'); + res.end(iifeCode); + } else if (pathname === '/web_src/js/__vite_iife.js.map') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(iifeMap); + } else { + next(); + } + }); + }, + async closeBundle() { + for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file)); + const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'})); + const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput; + const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.')); + if (!entry) throw new Error('IIFE build produced no output'); + const manifestPath = join(outDir, '.vite', 'manifest.json'); + writeFileSync(manifestPath, JSON.stringify({ + ...JSON.parse(readFileSync(manifestPath, 'utf8')), + 'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true}, + }, null, 2)); + }, + }; +} + +// In reduced sourcemap mode, only keep sourcemaps for main files +function reducedSourcemapPlugin(): Plugin { + return { + name: 'reduced-sourcemap', + apply: 'build', + closeBundle() { + if (enableSourcemap !== 'reduced') return; + for (const file of globSync('{js,css}/*.map', {cwd: outDir})) { + if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file)); + } + }, + }; +} + +// Filter out legacy font formats from CSS, keeping only woff2 +function filterCssUrlPlugin(): Plugin { + return { + name: 'filter-css-url', + enforce: 'pre', + transform(code, id) { + if (!id.endsWith('.css') || !id.includes('katex')) return null; + return code.replace(/,\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("[^"]*"\)/gi, ''); + }, + }; +} + +const viteDevServerPort = Number(env.FRONTEND_DEV_SERVER_PORT) || 3001; +const viteDevPortFilePath = join(outDir, '.vite', 'dev-port'); + +// Write the Vite dev server's actual port to a file so the Go server can discover it for proxying. +function viteDevServerPortPlugin(): Plugin { + return { + name: 'vite-dev-server-port', + apply: 'serve', + configureServer(server) { + server.httpServer!.once('listening', () => { + const addr = server.httpServer!.address(); + if (typeof addr === 'object' && addr) { + writeFileSync(viteDevPortFilePath, String(addr.port)); + } + }); + }, + }; +} + +export default defineConfig(commonViteOpts({ + appType: 'custom', // Go serves all HTML, disable Vite's HTML handling + clearScreen: false, + server: { + port: viteDevServerPort, + open: false, + host: '0.0.0.0', + strictPort: false, + fs: { + // VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access + // Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN) + strict: true, + allow: [ + 'assets', + 'node_modules', + 'public', + 'web_src', + // do not add any other directories here, unless you are absolutely sure it's safe to expose them to the public + ], + }, + headers: { + 'Cache-Control': 'no-store', // prevent browser disk cache + }, + warmup: { + clientFiles: [ + // warmup the important entry points + 'web_src/js/index.ts', + 'web_src/css/index.css', + 'web_src/css/themes/*.css', + ], + }, + }, + build: { + modulePreload: false, + manifest: true, + rolldownOptions: { + input: { + index: join(import.meta.dirname, 'web_src/js/index.ts'), + swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'), + 'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'), + 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'), + ...(!isProduction && { + devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'), + }), + ...themes, + }, + output: { + entryFileNames: 'js/[name].[hash:8].js', + chunkFileNames: 'js/[name].[hash:8].js', + assetFileNames: ({names}) => { + const name = names[0]; + if (name.endsWith('.css')) return 'css/[name].[hash:8].css'; + if (/\.(ttf|woff2?)$/.test(name)) return 'fonts/[name].[hash:8].[ext]'; + return '[name].[hash:8].[ext]'; + }, + }, + }, + }, + worker: { + rolldownOptions: { + ...commonRolldownOptions, + output: { + entryFileNames: 'js/[name].[hash:8].js', + }, + }, + }, + css: { + transformer: 'postcss', + postcss: { + plugins: [ + tailwindcss(tailwindConfig), + ], + }, + }, + define: { + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: false, + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, + }, + plugins: [ + iifePlugin(), + viteDevServerPortPlugin(), + reducedSourcemapPlugin(), + filterCssUrlPlugin(), + stringPlugin(), + vuePlugin({ + template: { + compilerOptions: { + isCustomElement: (tag) => webComponents.has(tag), + }, + }, + }), + isProduction ? licensePlugin({ + thirdParty: { + output: { + file: join(import.meta.dirname, 'public/assets/licenses.txt'), + template(deps) { + const line = '-'.repeat(80); + const goJson = readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8'); + const goModules = JSON.parse(goJson).map(({name, licenseText}: {name: string, licenseText: string}) => { + return {name, body: formatLicenseText(licenseText)}; + }); + const jsModules = deps.map((dep) => { + return {name: dep.name, version: dep.version, body: formatLicenseText(dep.licenseText ?? '')}; + }); + const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name)); + return modules.map(({name, version, body}: {name: string, version?: string, body: string}) => { + const title = version ? `${name}@${version}` : name; + return `${line}\n${title}\n${line}\n${body}`; + }).join('\n'); + }, + }, + allow(dependency) { + if (dependency.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33 + return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dependency.license ?? ''); + }, + }, + }) : { + name: 'dev-licenses-stub', + closeBundle() { + writeFileSync(join(outDir, 'licenses.txt'), 'Licenses are disabled during development'); + }, + }, + ], +})); diff --git a/web_src/css/base.css b/web_src/css/base.css index 60317887bad..b660e19ac4f 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -538,6 +538,58 @@ strong.attention-caution, svg.attention-caution { overflow-menu { border-bottom: 1px solid var(--color-secondary) !important; display: flex; + position: relative; +} + +overflow-menu .overflow-menu-popup { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 100; + background-color: var(--color-menu); + color: var(--color-text); + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); + box-shadow: 0 6px 18px var(--color-shadow); + padding: 4px 0; +} + +overflow-menu .overflow-menu-popup::before, +overflow-menu .overflow-menu-popup::after { + content: ""; + position: absolute; + right: 10px; + border: 8px solid transparent; +} + +overflow-menu .overflow-menu-popup::before { + bottom: 100%; + border-bottom-color: var(--color-secondary); +} + +overflow-menu .overflow-menu-popup::after { + bottom: calc(100% - 1px); + border-bottom-color: var(--color-menu); +} + +overflow-menu .overflow-menu-popup > .item { + display: flex; + align-items: center; + padding: 9px 18px !important; + color: var(--color-text) !important; + background: transparent !important; + text-decoration: none; + gap: 10px; + width: 100%; +} + +overflow-menu .overflow-menu-popup > .item:hover, +overflow-menu .overflow-menu-popup > .item:focus { + background: var(--color-hover) !important; +} + +overflow-menu .overflow-menu-popup > .item.active { + background: var(--color-active) !important; } overflow-menu .overflow-menu-items { diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts index ca38ac874e1..f88f4900637 100644 --- a/web_src/js/bootstrap.ts +++ b/web_src/js/bootstrap.ts @@ -1,82 +1,12 @@ // DO NOT IMPORT window.config HERE! // to make sure the error handler always works, we should never import `window.config`, because // some user's custom template breaks it. -import type {Intent} from './types.ts'; -import {html} from './utils/html.ts'; +import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts'; -// This sets up the URL prefix used in webpack's chunk loading. -// This file must be imported before any lazy-loading is being attempted. -window.__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`; - -export function shouldIgnoreError(err: Error) { - const ignorePatterns: Array = [ - // https://github.com/go-gitea/gitea/issues/30861 - // https://github.com/microsoft/monaco-editor/issues/4496 - // https://github.com/microsoft/monaco-editor/issues/4679 - /\/assets\/js\/.*monaco/, - ]; - for (const pattern of ignorePatterns) { - if (pattern.test(err.stack ?? '')) return true; - } - return false; -} - -export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { - const msgContainer = document.querySelector('.page-content') ?? document.body; - if (!msgContainer) { - alert(`${msgType}: ${msg}`); - return; - } - const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages - let msgDiv = msgContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); - if (!msgDiv) { - const el = document.createElement('div'); - el.innerHTML = html`
`; - msgDiv = el.childNodes[0] as HTMLDivElement; - } - // merge duplicated messages into "the message (count)" format - const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1; - msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact); - msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString()); - msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); - msgContainer.prepend(msgDiv); -} - -function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) { - const err = error ?? reason; - const assetBaseUrl = String(new URL(window.__webpack_public_path__, window.location.origin)); - const {runModeIsProd} = window.config ?? {}; - - // `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a - // non-critical event from the browser. We log them but don't show them to users. Examples: - // - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors - // - https://github.com/mozilla-mobile/firefox-ios/issues/10817 - // - https://github.com/go-gitea/gitea/issues/20240 - if (!err) { - if (message) console.error(new Error(message)); - if (runModeIsProd) return; - } - - if (err instanceof Error) { - // If the error stack trace does not include the base URL of our script assets, it likely came - // from a browser extension or inline script. Do not show such errors in production. - if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return; - // Ignore some known errors that are unable to fix - if (shouldIgnoreError(err)) return; - } - - let msg = err?.message ?? message; - if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`; - const dot = msg.endsWith('.') ? '' : '.'; - const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type; - showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`); -} - -function initGlobalErrorHandler() { - if (window._globalHandlerErrors?._inited) { - showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`); - return; - } +// A module should not be imported twice, otherwise there will be bugs when a module has its internal states. +// A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes, +// It will generate duplicate IDs (ps: don't try to use "random" to fix, it is just a real example to show the importance of "do not import a module twice") +if (!window._globalHandlerErrors?._inited) { if (!window.config) { showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`); } @@ -90,5 +20,3 @@ function initGlobalErrorHandler() { // events directly window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any; } - -initGlobalErrorHandler(); diff --git a/web_src/js/features/captcha.ts b/web_src/js/features/captcha.ts index 01b50530265..08513fe6ba5 100644 --- a/web_src/js/features/captcha.ts +++ b/web_src/js/features/captcha.ts @@ -34,7 +34,7 @@ export async function initCaptcha() { break; } case 'm-captcha': { - const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue'); + const mCaptcha = await import('@mcaptcha/vanilla-glue'); // FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run // * the "vanilla-glue" has some problems with es6 module. diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts index 6d30d816857..1abd960366e 100644 --- a/web_src/js/features/citation.ts +++ b/web_src/js/features/citation.ts @@ -6,10 +6,10 @@ const {pageData} = window.config; async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) { const [{Cite, plugins}] = await Promise.all([ - import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'), - import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'), - import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'), - import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'), + import('@citation-js/core'), + import('@citation-js/plugin-software-formats'), + import('@citation-js/plugin-bibtex'), + import('@citation-js/plugin-csl'), ]); const citationFileContent = pageData.citationFileContent!; const config = plugins.config.get('@bibtex'); diff --git a/web_src/js/features/code-frequency.ts b/web_src/js/features/code-frequency.ts index da7cd6b2c00..475379ac14a 100644 --- a/web_src/js/features/code-frequency.ts +++ b/web_src/js/features/code-frequency.ts @@ -4,7 +4,7 @@ export async function initRepoCodeFrequency() { const el = document.querySelector('#repo-code-frequency-chart'); if (!el) return; - const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue'); + const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue'); try { const View = createApp(RepoCodeFrequency, { locale: { diff --git a/web_src/js/features/codeeditor.ts b/web_src/js/features/codeeditor.ts index dc3f2fad81b..58acf1494d5 100644 --- a/web_src/js/features/codeeditor.ts +++ b/web_src/js/features/codeeditor.ts @@ -129,7 +129,7 @@ function updateTheme(monaco: Monaco): void { type CreateMonacoOpts = MonacoOpts & {language?: string}; export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> { - const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor'); + const monaco = await import('../modules/monaco.ts'); initLanguages(monaco); let {language, ...other} = opts; diff --git a/web_src/js/features/colorpicker.ts b/web_src/js/features/colorpicker.ts index face4ef228f..6a14774bfc4 100644 --- a/web_src/js/features/colorpicker.ts +++ b/web_src/js/features/colorpicker.ts @@ -6,8 +6,8 @@ export async function initColorPickers() { registerGlobalInitFunc('initColorPicker', async (el) => { if (!imported) { await Promise.all([ - import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'), - import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'), + import('vanilla-colorful/hex-color-picker.js'), + import('../../css/features/colorpicker.css'), ]); imported = true; } diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 36af0870899..fd37e307f76 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -1,5 +1,5 @@ import {GET, POST} from '../modules/fetch.ts'; -import {showGlobalErrorMessage} from '../bootstrap.ts'; +import {showGlobalErrorMessage} from '../modules/errors.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {addDelegatedEventListener, queryElems} from '../utils/dom.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 5b470ea03d5..468f3fc5ca6 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -319,8 +319,8 @@ export class ComboMarkdownEditor { async switchToEasyMDE() { if (this.easyMDE) return; const [{default: EasyMDE}] = await Promise.all([ - import(/* webpackChunkName: "easymde" */'easymde'), - import(/* webpackChunkName: "easymde" */'../../../css/easymde.css'), + import('easymde'), + import('../../../css/easymde.css'), ]); const easyMDEOpt: EasyMDE.Options = { autoDownloadFontAwesome: false, diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts index 9fd48697fa6..a36689bfc27 100644 --- a/web_src/js/features/comp/Cropper.ts +++ b/web_src/js/features/comp/Cropper.ts @@ -7,7 +7,7 @@ type CropperOpts = { }; async function initCompCropper({container, fileInput, imageSource}: CropperOpts) { - const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs'); + const {default: Cropper} = await import('cropperjs'); let currentFileName = ''; let currentFileLastModified = 0; const cropper = new Cropper(imageSource, { diff --git a/web_src/js/features/contributors.ts b/web_src/js/features/contributors.ts index 95fc81f5b36..d28d18eacc2 100644 --- a/web_src/js/features/contributors.ts +++ b/web_src/js/features/contributors.ts @@ -4,7 +4,7 @@ export async function initRepoContributors() { const el = document.querySelector('#repo-contributors-chart'); if (!el) return; - const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue'); + const {default: RepoContributors} = await import('../components/RepoContributors.vue'); try { const View = createApp(RepoContributors, { repoLink: el.getAttribute('data-repo-link'), diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index fedcff2162b..55c0e3c7a50 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -19,8 +19,8 @@ export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done'; async function createDropzone(el: HTMLElement, opts: DropzoneOptions) { const [{default: Dropzone}] = await Promise.all([ - import(/* webpackChunkName: "dropzone" */'dropzone'), - import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'), + import('dropzone'), + import('dropzone/dist/dropzone.css'), ]); return new Dropzone(el, opts); } diff --git a/web_src/js/features/heatmap.ts b/web_src/js/features/heatmap.ts index 95004096d8a..341e014bfc6 100644 --- a/web_src/js/features/heatmap.ts +++ b/web_src/js/features/heatmap.ts @@ -45,7 +45,7 @@ export async function initHeatmap() { noDataText: el.getAttribute('data-locale-no-contributions'), }; - const {default: ActivityHeatmap} = await import(/* webpackChunkName: "ActivityHeatmap" */ '../components/ActivityHeatmap.vue'); + const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue'); const View = createApp(ActivityHeatmap, {values, locale}); View.mount(el); el.classList.remove('is-loading'); diff --git a/web_src/js/features/recent-commits.ts b/web_src/js/features/recent-commits.ts index b7f7c499873..6ad53a238c0 100644 --- a/web_src/js/features/recent-commits.ts +++ b/web_src/js/features/recent-commits.ts @@ -4,7 +4,7 @@ export async function initRepoRecentCommits() { const el = document.querySelector('#repo-recent-commits-chart'); if (!el) return; - const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue'); + const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue'); try { const View = createApp(RepoRecentCommits, { locale: { diff --git a/web_src/js/features/repo-findfile.ts b/web_src/js/features/repo-findfile.ts index 8d306b2bab8..962f8b84c12 100644 --- a/web_src/js/features/repo-findfile.ts +++ b/web_src/js/features/repo-findfile.ts @@ -69,7 +69,7 @@ export function filterRepoFilesWeighted(files: Array, filter: string) { export function initRepoFileSearch() { registerGlobalInitFunc('initRepoFileSearch', async (el) => { - const {default: RepoFileSearch} = await import(/* webpackChunkName: "RepoFileSearch" */ '../components/RepoFileSearch.vue'); + const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue'); createApp(RepoFileSearch, { repoLink: el.getAttribute('data-repo-link'), currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'), diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts index 093f484b42c..58dbf1790eb 100644 --- a/web_src/js/features/repo-issue-pull.ts +++ b/web_src/js/features/repo-issue-pull.ts @@ -66,7 +66,7 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) { const el = box.querySelector('#pull-request-merge-form'); if (!el) return; - const {default: PullRequestMergeForm} = await import(/* webpackChunkName: "PullRequestMergeForm" */ '../components/PullRequestMergeForm.vue'); + const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue'); const view = createApp(PullRequestMergeForm); view.mount(el); } diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts index 1a011c33a19..462a925ab67 100644 --- a/web_src/js/features/tribute.ts +++ b/web_src/js/features/tribute.ts @@ -5,7 +5,7 @@ import type {TributeCollection} from 'tributejs'; import type {Mention} from '../types.ts'; export async function attachTribute(element: HTMLElement) { - const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); + const {default: Tribute} = await import('tributejs'); const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url'); const emojiCollection: TributeCollection = { // emojis diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index f6e0a109b01..2a6f86b65ec 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -22,8 +22,8 @@ interface Window { config: { appUrl: string, appSubUrl: string, - assetVersionEncoded: string, assetUrlPrefix: string, + sharedWorkerUri: string, runModeIsProd: boolean, customEmojis: Record, pageData: Record & { @@ -64,6 +64,10 @@ interface Window { codeEditors: any[], // export editor for customization localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings, + MonacoEnvironment?: { + getWorker: (workerId: string, label: string) => Worker, + }, + // various captcha plugins grecaptcha: any, turnstile: any, @@ -71,3 +75,8 @@ interface Window { // do not add more properties here unless it is a must } + +declare module '*?worker' { + const workerConstructor: new () => Worker; + export default workerConstructor; +} diff --git a/web_src/js/globals.ts b/web_src/js/globals.ts index 955515d2502..9cd66d8322b 100644 --- a/web_src/js/globals.ts +++ b/web_src/js/globals.ts @@ -1,2 +1,16 @@ -import jquery from 'jquery'; -window.$ = window.jQuery = jquery; // only for Fomantic UI +import jquery from 'jquery'; // eslint-disable-line no-restricted-imports +import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports +import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports + +// Some users still use inline scripts and expect jQuery to be available globally. +// To avoid breaking existing users and custom plugins, import jQuery globally without ES module. +window.$ = window.jQuery = jquery; + +// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded +// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization. +// ref: https://github.com/bigskysoftware/htmx/pull/3365 +window.htmx = htmx; + +// https://htmx.org/reference/#config +htmx.config.requestClass = 'is-loading'; +htmx.config.scrollIntoViewOnBoost = false; diff --git a/web_src/js/htmx.ts b/web_src/js/htmx.ts deleted file mode 100644 index acc3df1d81d..00000000000 --- a/web_src/js/htmx.ts +++ /dev/null @@ -1,26 +0,0 @@ -import htmx from 'htmx.org'; -import 'idiomorph/htmx'; -import type {HtmxResponseInfo} from 'htmx.org'; -import {showErrorToast} from './modules/toast.ts'; - -type HtmxEvent = Event & {detail: HtmxResponseInfo}; - -export function initHtmx() { - window.htmx = htmx; - - // https://htmx.org/reference/#config - htmx.config.requestClass = 'is-loading'; - htmx.config.scrollIntoViewOnBoost = false; - - // https://htmx.org/events/#htmx:sendError - document.body.addEventListener('htmx:sendError', (event: Partial) => { - // TODO: add translations - showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`); - }); - - // https://htmx.org/events/#htmx:responseError - document.body.addEventListener('htmx:responseError', (event: Partial) => { - // TODO: add translations - showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`); - }); -} diff --git a/web_src/js/iife.ts b/web_src/js/iife.ts new file mode 100644 index 00000000000..218519c59a3 --- /dev/null +++ b/web_src/js/iife.ts @@ -0,0 +1,11 @@ +// This file is the entry point for the code which should block the page rendering, it is compiled by our "iife" vite plugin + +// bootstrap module must be the first one to be imported, it handles global errors +import './bootstrap.ts'; + +// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml) +// so load globals (including jQuery) as early as possible +import './globals.ts'; + +import './webcomponents/index.ts'; +import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts deleted file mode 100644 index 19a61b0e40f..00000000000 --- a/web_src/js/index-domready.ts +++ /dev/null @@ -1,175 +0,0 @@ -import '../fomantic/build/fomantic.js'; - -import {initHtmx} from './htmx.ts'; -import {initDashboardRepoList} from './features/dashboard.ts'; -import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; -import {initRepoGraphGit} from './features/repo-graph.ts'; -import {initHeatmap} from './features/heatmap.ts'; -import {initImageDiff} from './features/imagediff.ts'; -import {initRepoMigration} from './features/repo-migration.ts'; -import {initRepoProject} from './features/repo-projects.ts'; -import {initTableSort} from './features/tablesort.ts'; -import {initAdminUserListSearchForm} from './features/admin/users.ts'; -import {initAdminConfigs} from './features/admin/config.ts'; -import {initMarkupAnchors} from './markup/anchors.ts'; -import {initNotificationCount} from './features/notification.ts'; -import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; -import {initStopwatch} from './features/stopwatch.ts'; -import {initRepoFileSearch} from './features/repo-findfile.ts'; -import {initMarkupContent} from './markup/content.ts'; -import {initRepoFileView} from './features/file-view.ts'; -import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; -import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; -import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; -import {initRepoTopicBar} from './features/repo-home.ts'; -import {initAdminCommon} from './features/admin/common.ts'; -import {initRepoCodeView} from './features/repo-code.ts'; -import {initSshKeyFormParser} from './features/sshkey-helper.ts'; -import {initUserSettings} from './features/user-settings.ts'; -import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts'; -import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts'; -import {initRepoDiffView} from './features/repo-diff.ts'; -import {initOrgTeam} from './features/org-team.ts'; -import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts'; -import {initRepoReleaseNew} from './features/repo-release.ts'; -import {initRepoEditor} from './features/repo-editor.ts'; -import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts'; -import {initInstall} from './features/install.ts'; -import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts'; -import {initRepoBranchButton} from './features/repo-branch.ts'; -import {initCommonOrganization} from './features/common-organization.ts'; -import {initRepoWikiForm} from './features/repo-wiki.ts'; -import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts'; -import {initCopyContent} from './features/copycontent.ts'; -import {initCaptcha} from './features/captcha.ts'; -import {initRepositoryActionView} from './features/repo-actions.ts'; -import {initGlobalTooltips} from './modules/tippy.ts'; -import {initGiteaFomantic} from './modules/fomantic.ts'; -import {initSubmitEventPolyfill} from './utils/dom.ts'; -import {initRepoIssueList} from './features/repo-issue-list.ts'; -import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts'; -import {initRepoContributors} from './features/contributors.ts'; -import {initRepoCodeFrequency} from './features/code-frequency.ts'; -import {initRepoRecentCommits} from './features/recent-commits.ts'; -import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts'; -import {initGlobalSelectorObserver} from './modules/observer.ts'; -import {initRepositorySearch} from './features/repo-search.ts'; -import {initColorPickers} from './features/colorpicker.ts'; -import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; -import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; -import {initGlobalFetchAction} from './features/common-fetch-action.ts'; -import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts'; -import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; -import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; -import {callInitFunctions} from './modules/init.ts'; -import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; -import {initActionsPermissionsForm} from './features/common-actions-permissions.ts'; -import {initGlobalShortcut} from './modules/shortcut.ts'; - -const initStartTime = performance.now(); -const initPerformanceTracer = callInitFunctions([ - initHtmx, - initSubmitEventPolyfill, - initGiteaFomantic, - - initGlobalComponent, - initGlobalDropdown, - initGlobalFetchAction, - initGlobalTooltips, - initGlobalButtonClickOnEnter, - initGlobalButtons, - initGlobalCopyToClipboardListener, - initGlobalEnterQuickSubmit, - initGlobalFormDirtyLeaveConfirm, - initGlobalComboMarkdownEditor, - initGlobalDeleteButton, - initGlobalInput, - initGlobalShortcut, - - initCommonOrganization, - initCommonIssueListQuickGoto, - - initCompSearchUserBox, - initCompWebHookEditor, - - initInstall, - - initCommmPageComponents, - - initHeatmap, - initImageDiff, - initMarkupAnchors, - initMarkupContent, - initSshKeyFormParser, - initStopwatch, - initTableSort, - initRepoFileSearch, - initCopyContent, - - initAdminCommon, - initAdminUserListSearchForm, - initAdminConfigs, - initAdminSelfCheck, - - initDashboardRepoList, - - initNotificationCount, - - initOrgTeam, - - initRepoActivityTopAuthorsChart, - initRepoArchiveLinks, - initRepoBranchButton, - initRepoCodeView, - initBranchSelectorTabs, - initRepoEllipsisButton, - initRepoDiffCommitBranchesAndTags, - initRepoEditor, - initRepoGraphGit, - initRepoIssueContentHistory, - initRepoIssueList, - initRepoIssueFilterItemLabel, - initRepoIssueSidebarDependency, - initRepoMigration, - initRepoMigrationStatusChecker, - initRepoProject, - initRepoPullRequestAllowMaintainerEdit, - initRepoPullRequestReview, - initRepoReleaseNew, - initRepoTopicBar, - initRepoViewFileTree, - initRepoWikiForm, - initRepository, - initRepositoryActionView, - initRepositorySearch, - initRepoContributors, - initRepoCodeFrequency, - initRepoRecentCommits, - - initCommitStatuses, - initCaptcha, - - initUserCheckAppUrl, - initUserAuthOauth2, - initUserAuthWebAuthn, - initUserAuthWebAuthnRegister, - initUserSettings, - initRepoDiffView, - initColorPickers, - - initOAuth2SettingsDisableCheckbox, - - initRepoFileView, - initActionsPermissionsForm, -]); - -// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. -initGlobalSelectorObserver(initPerformanceTracer); -if (initPerformanceTracer) initPerformanceTracer.printResults(); - -const initDur = performance.now() - initStartTime; -if (initDur > 500) { - console.error(`slow init functions took ${initDur.toFixed(3)}ms`); -} - -document.dispatchEvent(new CustomEvent('gitea:index-ready')); diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 2de29f52b94..e0b4a3e521a 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -1,29 +1,188 @@ -// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors -import './bootstrap.ts'; +import '../fomantic/build/fomantic.js'; +import '../css/index.css'; +import type {HtmxResponseInfo} from 'htmx.org'; +import {showErrorToast} from './modules/toast.ts'; -// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml) -// so load globals (including jQuery) as early as possible -import './globals.ts'; +import {initDashboardRepoList} from './features/dashboard.ts'; +import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; +import {initRepoGraphGit} from './features/repo-graph.ts'; +import {initHeatmap} from './features/heatmap.ts'; +import {initImageDiff} from './features/imagediff.ts'; +import {initRepoMigration} from './features/repo-migration.ts'; +import {initRepoProject} from './features/repo-projects.ts'; +import {initTableSort} from './features/tablesort.ts'; +import {initAdminUserListSearchForm} from './features/admin/users.ts'; +import {initAdminConfigs} from './features/admin/config.ts'; +import {initMarkupAnchors} from './markup/anchors.ts'; +import {initNotificationCount} from './features/notification.ts'; +import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; +import {initStopwatch} from './features/stopwatch.ts'; +import {initRepoFileSearch} from './features/repo-findfile.ts'; +import {initMarkupContent} from './markup/content.ts'; +import {initRepoFileView} from './features/file-view.ts'; +import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; +import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; +import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; +import {initRepoTopicBar} from './features/repo-home.ts'; +import {initAdminCommon} from './features/admin/common.ts'; +import {initRepoCodeView} from './features/repo-code.ts'; +import {initSshKeyFormParser} from './features/sshkey-helper.ts'; +import {initUserSettings} from './features/user-settings.ts'; +import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts'; +import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts'; +import {initRepoDiffView} from './features/repo-diff.ts'; +import {initOrgTeam} from './features/org-team.ts'; +import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts'; +import {initRepoReleaseNew} from './features/repo-release.ts'; +import {initRepoEditor} from './features/repo-editor.ts'; +import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts'; +import {initInstall} from './features/install.ts'; +import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts'; +import {initRepoBranchButton} from './features/repo-branch.ts'; +import {initCommonOrganization} from './features/common-organization.ts'; +import {initRepoWikiForm} from './features/repo-wiki.ts'; +import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts'; +import {initCopyContent} from './features/copycontent.ts'; +import {initCaptcha} from './features/captcha.ts'; +import {initRepositoryActionView} from './features/repo-actions.ts'; +import {initGlobalTooltips} from './modules/tippy.ts'; +import {initGiteaFomantic} from './modules/fomantic.ts'; +import {initSubmitEventPolyfill} from './utils/dom.ts'; +import {initRepoIssueList} from './features/repo-issue-list.ts'; +import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts'; +import {initRepoContributors} from './features/contributors.ts'; +import {initRepoCodeFrequency} from './features/code-frequency.ts'; +import {initRepoRecentCommits} from './features/recent-commits.ts'; +import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts'; +import {initGlobalSelectorObserver} from './modules/observer.ts'; +import {initRepositorySearch} from './features/repo-search.ts'; +import {initColorPickers} from './features/colorpicker.ts'; +import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; +import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; +import {initGlobalFetchAction} from './features/common-fetch-action.ts'; +import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts'; +import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; +import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; +import {callInitFunctions} from './modules/init.ts'; +import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; +import {initActionsPermissionsForm} from './features/common-actions-permissions.ts'; +import {initGlobalShortcut} from './modules/shortcut.ts'; -import './webcomponents/index.ts'; -import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts -import {onDomReady} from './utils/dom.ts'; +const initStartTime = performance.now(); +const initPerformanceTracer = callInitFunctions([ + initSubmitEventPolyfill, + initGiteaFomantic, -// TODO: There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded -// Then importing the htmx in our onDomReady will make htmx skip its initialization. -// If the bug would be fixed (https://github.com/bigskysoftware/htmx/pull/3365), then we can only import htmx in "onDomReady" -import 'htmx.org'; + initGlobalComponent, + initGlobalDropdown, + initGlobalFetchAction, + initGlobalTooltips, + initGlobalButtonClickOnEnter, + initGlobalButtons, + initGlobalCopyToClipboardListener, + initGlobalEnterQuickSubmit, + initGlobalFormDirtyLeaveConfirm, + initGlobalComboMarkdownEditor, + initGlobalDeleteButton, + initGlobalInput, + initGlobalShortcut, -onDomReady(async () => { - // when navigate before the import complete, there will be an error from webpack chunk loader: - // JavaScript promise rejection: Loading chunk index-domready failed. - try { - await import(/* webpackChunkName: "index-domready" */'./index-domready.ts'); - } catch (e) { - if (e.name === 'ChunkLoadError') { - console.error('Error loading index-domready:', e); - } else { - throw e; - } - } + initCommonOrganization, + initCommonIssueListQuickGoto, + + initCompSearchUserBox, + initCompWebHookEditor, + + initInstall, + + initCommmPageComponents, + + initHeatmap, + initImageDiff, + initMarkupAnchors, + initMarkupContent, + initSshKeyFormParser, + initStopwatch, + initTableSort, + initRepoFileSearch, + initCopyContent, + + initAdminCommon, + initAdminUserListSearchForm, + initAdminConfigs, + initAdminSelfCheck, + + initDashboardRepoList, + + initNotificationCount, + + initOrgTeam, + + initRepoActivityTopAuthorsChart, + initRepoArchiveLinks, + initRepoBranchButton, + initRepoCodeView, + initBranchSelectorTabs, + initRepoEllipsisButton, + initRepoDiffCommitBranchesAndTags, + initRepoEditor, + initRepoGraphGit, + initRepoIssueContentHistory, + initRepoIssueList, + initRepoIssueFilterItemLabel, + initRepoIssueSidebarDependency, + initRepoMigration, + initRepoMigrationStatusChecker, + initRepoProject, + initRepoPullRequestAllowMaintainerEdit, + initRepoPullRequestReview, + initRepoReleaseNew, + initRepoTopicBar, + initRepoViewFileTree, + initRepoWikiForm, + initRepository, + initRepositoryActionView, + initRepositorySearch, + initRepoContributors, + initRepoCodeFrequency, + initRepoRecentCommits, + + initCommitStatuses, + initCaptcha, + + initUserCheckAppUrl, + initUserAuthOauth2, + initUserAuthWebAuthn, + initUserAuthWebAuthnRegister, + initUserSettings, + initRepoDiffView, + initColorPickers, + + initOAuth2SettingsDisableCheckbox, + + initRepoFileView, + initActionsPermissionsForm, +]); + +// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. +initGlobalSelectorObserver(initPerformanceTracer); +if (initPerformanceTracer) initPerformanceTracer.printResults(); + +const initDur = performance.now() - initStartTime; +if (initDur > 500) { + console.error(`slow init functions took ${initDur.toFixed(3)}ms`); +} + +// https://htmx.org/events/#htmx:sendError +type HtmxEvent = Event & {detail: HtmxResponseInfo}; +document.body.addEventListener('htmx:sendError', (event) => { + // TODO: add translations + showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`); }); +// https://htmx.org/events/#htmx:responseError +document.body.addEventListener('htmx:responseError', (event) => { + // TODO: add translations + showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`); +}); + +document.dispatchEvent(new CustomEvent('gitea:index-ready')); diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/markup/asciicast.ts index 4596327876c..90515e1363c 100644 --- a/web_src/js/markup/asciicast.ts +++ b/web_src/js/markup/asciicast.ts @@ -3,8 +3,8 @@ import {queryElems} from '../utils/dom.ts'; export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise { queryElems(elMarkup, '.asciinema-player-container', async (el) => { const [player] = await Promise.all([ - import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), - import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), + import('asciinema-player'), + import('asciinema-player/dist/bundle/asciinema-player.css'), ]); player.create(el.getAttribute('data-asciinema-player-src')!, el, { diff --git a/web_src/js/markup/math.ts b/web_src/js/markup/math.ts index bc118137a10..a3ee102ccde 100644 --- a/web_src/js/markup/math.ts +++ b/web_src/js/markup/math.ts @@ -16,8 +16,8 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise { // .markup code.language-math' queryElems(elMarkup, 'code.language-math', async (el) => { const [{default: katex}] = await Promise.all([ - import(/* webpackChunkName: "katex" */'katex'), - import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), + import('katex'), + import('katex/dist/katex.css'), ]); const MAX_CHARS = 1000; diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index 5148ff377ca..aaf6da6805d 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -72,8 +72,8 @@ export function sourceNeedsElk(source: string) { } async function loadMermaid(needElkRender: boolean) { - const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid'); - const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null; + const mermaidPromise = import('mermaid'); + const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null; const results = await Promise.all([mermaidPromise, elkPromise]); return { mermaid: results[0].default, diff --git a/web_src/js/markup/refissue.ts b/web_src/js/markup/refissue.ts index f2fcd24f39d..b17f452dd4d 100644 --- a/web_src/js/markup/refissue.ts +++ b/web_src/js/markup/refissue.ts @@ -20,7 +20,7 @@ function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) { const el = document.createElement('div'); const onShowAsync = async () => { - const {default: ContextPopup} = await import(/* webpackChunkName: "ContextPopup" */ '../components/ContextPopup.vue'); + const {default: ContextPopup} = await import('../components/ContextPopup.vue'); const view = createApp(ContextPopup, { // backend: GetIssueInfo loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`, diff --git a/web_src/js/bootstrap.test.ts b/web_src/js/modules/errors.test.ts similarity index 70% rename from web_src/js/bootstrap.test.ts rename to web_src/js/modules/errors.test.ts index 9d163ebbb83..c860a3f7cb1 100644 --- a/web_src/js/bootstrap.test.ts +++ b/web_src/js/modules/errors.test.ts @@ -1,4 +1,4 @@ -import {showGlobalErrorMessage, shouldIgnoreError} from './bootstrap.ts'; +import {showGlobalErrorMessage, shouldIgnoreError} from './errors.ts'; test('showGlobalErrorMessage', () => { document.body.innerHTML = '
'; @@ -13,9 +13,9 @@ test('showGlobalErrorMessage', () => { test('shouldIgnoreError', () => { for (const url of [ - 'https://gitea.test/assets/js/monaco.b359ef7e.js', - 'https://gitea.test/assets/js/monaco-editor.4a969118.worker.js', - 'https://gitea.test/assets/js/vendors-node_modules_pnpm_monaco-editor_0_55_1_node_modules_monaco-editor_esm_vs_base_common_-e11c7c.966a028d.js', + 'https://gitea.test/assets/js/monaco.D14TzjS9.js', + 'https://gitea.test/assets/js/editor.api2.BdhK7zNg.js', + 'https://gitea.test/assets/js/editor.worker.BYgvyFya.js', ]) { const err = new Error('test'); err.stack = `Error: test\n at ${url}:1:1`; diff --git a/web_src/js/modules/errors.ts b/web_src/js/modules/errors.ts new file mode 100644 index 00000000000..3ec01b3eb7c --- /dev/null +++ b/web_src/js/modules/errors.ts @@ -0,0 +1,67 @@ +// keep this file lightweight, it's imported into IIFE chunk in bootstrap +import {html} from '../utils/html.ts'; +import type {Intent} from '../types.ts'; + +export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { + const msgContainer = document.querySelector('.page-content') ?? document.body; + if (!msgContainer) { + alert(`${msgType}: ${msg}`); + return; + } + const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages + let msgDiv = msgContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); + if (!msgDiv) { + const el = document.createElement('div'); + el.innerHTML = html`
`; + msgDiv = el.childNodes[0] as HTMLDivElement; + } + // merge duplicated messages into "the message (count)" format + const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1; + msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact); + msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString()); + msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); + msgContainer.prepend(msgDiv); +} + +export function shouldIgnoreError(err: Error) { + const ignorePatterns: Array = [ + // https://github.com/go-gitea/gitea/issues/30861 + // https://github.com/microsoft/monaco-editor/issues/4496 + // https://github.com/microsoft/monaco-editor/issues/4679 + /\/assets\/js\/.*(monaco|editor\.(api|worker))/, + ]; + for (const pattern of ignorePatterns) { + if (pattern.test(err.stack ?? '')) return true; + } + return false; +} + +export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) { + const err = error ?? reason; + const assetBaseUrl = String(new URL(`${window.config?.assetUrlPrefix ?? '/assets'}/`, window.location.origin)); + const {runModeIsProd} = window.config ?? {}; + + // `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a + // non-critical event from the browser. We log them but don't show them to users. Examples: + // - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors + // - https://github.com/mozilla-mobile/firefox-ios/issues/10817 + // - https://github.com/go-gitea/gitea/issues/20240 + if (!err) { + if (message) console.error(new Error(message)); + if (runModeIsProd) return; + } + + if (err instanceof Error) { + // If the error stack trace does not include the base URL of our script assets, it likely came + // from a browser extension or inline script. Do not show such errors in production. + if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return; + // Ignore some known errors that are unable to fix + if (shouldIgnoreError(err)) return; + } + + let msg = err?.message ?? message; + if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`; + const dot = msg.endsWith('.') ? '' : '.'; + const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type; + showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`); +} diff --git a/web_src/js/modules/fomantic.ts b/web_src/js/modules/fomantic.ts index 4b1dbc4f626..ee45f676ba4 100644 --- a/web_src/js/modules/fomantic.ts +++ b/web_src/js/modules/fomantic.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import {initAriaCheckboxPatch} from './fomantic/checkbox.ts'; import {initAriaFormFieldPatch} from './fomantic/form.ts'; import {initAriaDropdownPatch} from './fomantic/dropdown.ts'; diff --git a/web_src/js/modules/fomantic/base.ts b/web_src/js/modules/fomantic/base.ts index a227d8123a3..f3953e60cdd 100644 --- a/web_src/js/modules/fomantic/base.ts +++ b/web_src/js/modules/fomantic/base.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import {generateElemId} from '../../utils/dom.ts'; export function linkLabelAndInput(label: Element, input: Element) { diff --git a/web_src/js/modules/fomantic/dimmer.ts b/web_src/js/modules/fomantic/dimmer.ts index cbdfac23cba..6782f0137d9 100644 --- a/web_src/js/modules/fomantic/dimmer.ts +++ b/web_src/js/modules/fomantic/dimmer.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import {queryElemChildren} from '../../utils/dom.ts'; export function initFomanticDimmer() { diff --git a/web_src/js/modules/fomantic/dropdown.ts b/web_src/js/modules/fomantic/dropdown.ts index 7f7f3611beb..b98a5cf3f41 100644 --- a/web_src/js/modules/fomantic/dropdown.ts +++ b/web_src/js/modules/fomantic/dropdown.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import type {FomanticInitFunction} from '../../types.ts'; import {generateElemId, queryElems} from '../../utils/dom.ts'; diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts index a96c7785e1a..1383692c985 100644 --- a/web_src/js/modules/fomantic/modal.ts +++ b/web_src/js/modules/fomantic/modal.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import type {FomanticInitFunction} from '../../types.ts'; import {queryElems} from '../../utils/dom.ts'; import {hideToastsFrom} from '../toast.ts'; diff --git a/web_src/js/modules/fomantic/tab.ts b/web_src/js/modules/fomantic/tab.ts index b9578c96375..4d1bd7e648d 100644 --- a/web_src/js/modules/fomantic/tab.ts +++ b/web_src/js/modules/fomantic/tab.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import {queryElemSiblings} from '../../utils/dom.ts'; export function initFomanticTab() { diff --git a/web_src/js/modules/fomantic/transition.ts b/web_src/js/modules/fomantic/transition.ts index 52c407c9c0d..c4eb1d75e90 100644 --- a/web_src/js/modules/fomantic/transition.ts +++ b/web_src/js/modules/fomantic/transition.ts @@ -1,5 +1,3 @@ -import $ from 'jquery'; - export function initFomanticTransition() { const transitionNopBehaviors = new Set([ 'clear queue', 'stop', 'stop all', 'destroy', diff --git a/web_src/js/modules/monaco.ts b/web_src/js/modules/monaco.ts new file mode 100644 index 00000000000..c8e1ff77655 --- /dev/null +++ b/web_src/js/modules/monaco.ts @@ -0,0 +1,17 @@ +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; +import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; + +window.MonacoEnvironment = { + getWorker(_: string, label: string) { + if (label === 'json') return new jsonWorker(); + if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker(); + if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker(); + if (label === 'typescript' || label === 'javascript') return new tsWorker(); + return new editorWorker(); + }, +}; + +export * from 'monaco-editor'; diff --git a/web_src/js/modules/sortable.ts b/web_src/js/modules/sortable.ts index f3515fcb8de..c49f36ba8be 100644 --- a/web_src/js/modules/sortable.ts +++ b/web_src/js/modules/sortable.ts @@ -3,7 +3,7 @@ import type SortableType from 'sortablejs'; export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise { // type reassigned because typescript derives the wrong type from this import - const {Sortable} = (await import(/* webpackChunkName: "sortablejs" */'sortablejs') as unknown as {Sortable: typeof SortableType}); + const {Sortable} = (await import('sortablejs') as unknown as {Sortable: typeof SortableType}); return new Sortable(el, { animation: 150, diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts index b730e30bb2e..64c32fbe81b 100644 --- a/web_src/js/modules/worker.ts +++ b/web_src/js/modules/worker.ts @@ -1,11 +1,11 @@ -const {appSubUrl, assetVersionEncoded} = window.config; +const {appSubUrl, sharedWorkerUri} = window.config; export class UserEventsSharedWorker { sharedWorker: SharedWorker; // options can be either a string (the debug name of the worker) or an object of type WorkerOptions constructor(options?: string | WorkerOptions) { - const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options); + const worker = new SharedWorker(sharedWorkerUri, options); this.sharedWorker = worker; worker.addEventListener('error', (event) => { console.error('worker error', event); diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index 6f3ee15d265..f997790af69 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -47,7 +47,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin { async render(container: HTMLElement, fileUrl: string): Promise { // TODO: height and/or max-height? - const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); + const OV = await import('online-3d-viewer'); const viewer = new OV.EmbeddedViewer(container, { backgroundColor: new OV.RGBAColor(59, 68, 76, 0), defaultColor: new OV.RGBColor(65, 131, 196), diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts index 40623be0557..c7040e96ef1 100644 --- a/web_src/js/render/plugins/pdf-viewer.ts +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -9,7 +9,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin { }, async render(container: HTMLElement, fileUrl: string): Promise { - const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); + const PDFObject = await import('pdfobject'); // TODO: the PDFObject library does not support dynamic height adjustment, container.style.height = `${window.innerHeight - 100}px`; if (!PDFObject.default.embed(fileUrl, container)) { diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts index 39c41db0424..20ab163d1a2 100644 --- a/web_src/js/standalone/devtest.ts +++ b/web_src/js/standalone/devtest.ts @@ -1,3 +1,4 @@ +import '../../css/standalone/devtest.css'; import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts'; type LevelMap = Record Toast | null>; diff --git a/web_src/js/standalone/external-render-iframe.ts b/web_src/js/standalone/external-render-iframe.ts index f8ec070785a..3b489f8ee38 100644 --- a/web_src/js/standalone/external-render-iframe.ts +++ b/web_src/js/standalone/external-render-iframe.ts @@ -11,6 +11,8 @@ RENDER_COMMAND = `echo '
('[role="menuitem"]'); if (e.shiftKey) { if (document.activeElement === items[0]) { e.preventDefault(); @@ -39,7 +62,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); - this.button?._tippy.hide(); + this.hidePopup(); this.button?.focus(); } else if (e.key === ' ' || e.code === 'Enter') { if (document.activeElement?.matches('[role="menuitem"]')) { @@ -48,20 +71,20 @@ window.customElements.define('overflow-menu', class extends HTMLElement { (document.activeElement as HTMLElement).click(); } } else if (e.key === 'ArrowDown') { - if (document.activeElement?.matches('.tippy-target')) { + if (document.activeElement === this.popup) { e.preventDefault(); e.stopPropagation(); - document.activeElement.querySelector('[role="menuitem"]:first-of-type')?.focus(); + this.popup.querySelector('[role="menuitem"]:first-of-type')?.focus(); } else if (document.activeElement?.matches('[role="menuitem"]')) { e.preventDefault(); e.stopPropagation(); (document.activeElement.nextElementSibling as HTMLElement)?.focus(); } } else if (e.key === 'ArrowUp') { - if (document.activeElement?.matches('.tippy-target')) { + if (document.activeElement === this.popup) { e.preventDefault(); e.stopPropagation(); - document.activeElement.querySelector('[role="menuitem"]:last-of-type')?.focus(); + this.popup.querySelector('[role="menuitem"]:last-of-type')?.focus(); } else if (document.activeElement?.matches('[role="menuitem"]')) { e.preventDefault(); e.stopPropagation(); @@ -69,16 +92,15 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } } }); - div.classList.add('tippy-target'); - this.handleItemClick(div, '.tippy-target > .item'); - this.tippyContent = div; - } // end if: no tippyContent and create a new one + this.handleItemClick(div, '.overflow-menu-popup > .item'); + this.popup = div; + } // end if: no popup and create a new one const itemFlexSpace = this.menuItemsEl.querySelector('.item-flex-space'); const itemOverFlowMenuButton = this.querySelector('.overflow-menu-button'); - // move items in tippy back into the menu items for subsequent measurement - for (const item of this.tippyItems || []) { + // move items in popup back into the menu items for subsequent measurement + for (const item of this.overflowItems || []) { if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) { this.menuItemsEl.append(item); } else { @@ -90,7 +112,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { // flex space and overflow menu are excluded from measurement itemFlexSpace?.style.setProperty('display', 'none', 'important'); itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important'); - this.tippyItems = []; + this.overflowItems = []; const menuRight = this.offsetLeft + this.offsetWidth; const menuItems = this.menuItemsEl.querySelectorAll('.item, .item-flex-space'); let afterFlexSpace = false; @@ -102,64 +124,64 @@ window.customElements.define('overflow-menu', class extends HTMLElement { if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true'); const itemRight = item.offsetLeft + item.offsetWidth; if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space - const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0; + const onlyLastItem = idx === menuItems.length - 1 && this.overflowItems.length === 0; const lastItemFit = onlyLastItem && menuRight - itemRight > 0; const moveToPopup = !onlyLastItem || !lastItemFit; - if (moveToPopup) this.tippyItems.push(item); + if (moveToPopup) this.overflowItems.push(item); } } itemFlexSpace?.style.removeProperty('display'); itemOverFlowMenuButton?.style.removeProperty('display'); // if there are no overflown items, remove any previously created button - if (!this.tippyItems?.length) { - const btn = this.querySelector('.overflow-menu-button'); - btn?._tippy?.destroy(); - btn?.remove(); + if (!this.overflowItems?.length) { + this.hidePopup(); + this.button?.remove(); + this.popup?.remove(); this.button = null; return; } - // remove aria role from items that moved from tippy to menu + // remove aria role from items that moved from popup to menu for (const item of menuItems) { - if (!this.tippyItems.includes(item)) { + if (!this.overflowItems.includes(item)) { item.removeAttribute('role'); } } - // move all items that overflow into tippy - for (const item of this.tippyItems) { + // move all items that overflow into popup + for (const item of this.overflowItems) { item.setAttribute('role', 'menuitem'); - this.tippyContent.append(item); + this.popup.append(item); } - // update existing tippy - if (this.button?._tippy) { - this.button._tippy.setContent(this.tippyContent); + // update existing popup + if (this.button) { this.updateButtonActivationState(); return; } - // create button initially + // create button and attach popup + const popupId = generateElemId('overflow-popup-'); + this.popup.id = popupId; + this.button = document.createElement('button'); this.button.classList.add('overflow-menu-button'); this.button.setAttribute('aria-label', window.config.i18n.more_items); + this.button.setAttribute('aria-haspopup', 'true'); + this.button.setAttribute('aria-expanded', 'false'); + this.button.setAttribute('aria-controls', popupId); this.button.innerHTML = octiconKebabHorizontal; - this.append(this.button); - createTippy(this.button, { - trigger: 'click', - hideOnClick: true, - interactive: true, - placement: 'bottom-end', - role: 'menu', - theme: 'menu', - content: this.tippyContent, - onShow: () => { // FIXME: onShown doesn't work (never be called) - setTimeout(() => { - this.tippyContent.focus(); - }, 0); - }, + this.button.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.popup.style.display === 'none') { + this.showPopup(); + } else { + this.hidePopup(); + } }); + this.append(this.button); + this.append(this.popup); this.updateButtonActivationState(); }); @@ -202,7 +224,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { handleItemClick(el: Element, selector: string) { addDelegatedEventListener(el, 'click', selector, () => { - this.button?._tippy?.hide(); + this.hidePopup(); this.updateButtonActivationState(); }); } @@ -239,5 +261,6 @@ window.customElements.define('overflow-menu', class extends HTMLElement { disconnectedCallback() { this.mutationObserver?.disconnect(); this.resizeObserver?.disconnect(); + document.removeEventListener('click', this.onClickOutside, true); } }); diff --git a/webpack.config.ts b/webpack.config.ts deleted file mode 100644 index e3ef996909d..00000000000 --- a/webpack.config.ts +++ /dev/null @@ -1,268 +0,0 @@ -import wrapAnsi from 'wrap-ansi'; -import AddAssetPlugin from 'add-asset-webpack-plugin'; -import LicenseCheckerWebpackPlugin from '@techknowlogick/license-checker-webpack-plugin'; -import MiniCssExtractPlugin from 'mini-css-extract-plugin'; -import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; -import {VueLoaderPlugin} from 'vue-loader'; -import {EsbuildPlugin} from 'esbuild-loader'; -import {parse} from 'node:path'; -import webpack, {type Configuration, type EntryObject} from 'webpack'; -import {fileURLToPath} from 'node:url'; -import {readFileSync, globSync} from 'node:fs'; -import {env} from 'node:process'; -import tailwindcss from 'tailwindcss'; -import tailwindConfig from './tailwind.config.ts'; - -const {SourceMapDevToolPlugin, DefinePlugin, EnvironmentPlugin} = webpack; -const formatLicenseText = (licenseText: string) => wrapAnsi(licenseText || '', 80).trim(); - -const themes: EntryObject = {}; -for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) { - themes[parse(path).name] = [`./${path}`]; -} - -const isProduction = env.NODE_ENV !== 'development'; - -// ENABLE_SOURCEMAP accepts the following values: -// true - all enabled, the default in development -// reduced - minimal sourcemaps, the default in production -// false - all disabled -let sourceMaps; -if ('ENABLE_SOURCEMAP' in env) { - sourceMaps = ['true', 'false'].includes(env.ENABLE_SOURCEMAP || '') ? env.ENABLE_SOURCEMAP : 'reduced'; -} else { - sourceMaps = isProduction ? 'reduced' : 'true'; -} - -// define which web components we use for Vue to not interpret them as Vue components -const webComponents = new Set([ - // our own, in web_src/js/webcomponents - 'overflow-menu', - 'origin-url', - // from dependencies - 'markdown-toolbar', - 'relative-time', - 'text-expander', -]); - -const filterCssImport = (url: string, ...args: Array) => { - const cssFile = args[1] || args[0]; // resourcePath is 2nd argument for url and 3rd for import - const importedFile = url.replace(/[?#].+/, '').toLowerCase(); - - if (cssFile.includes('fomantic')) { - if (importedFile.includes('brand-icons')) return false; - if (/(eot|ttf|otf|woff|svg)$/i.test(importedFile)) return false; - } - - if (cssFile.includes('katex') && /(ttf|woff)$/i.test(importedFile)) { - return false; - } - - return true; -}; - -export default { - mode: isProduction ? 'production' : 'development', - entry: { - index: [ - fileURLToPath(new URL('web_src/js/index.ts', import.meta.url)), - fileURLToPath(new URL('web_src/css/index.css', import.meta.url)), - ], - swagger: [ - fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)), - fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)), - ], - 'external-render-iframe': [ - fileURLToPath(new URL('web_src/js/standalone/external-render-iframe.ts', import.meta.url)), - fileURLToPath(new URL('web_src/css/standalone/external-render-iframe.css', import.meta.url)), - ], - 'eventsource.sharedworker': [ - fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)), - ], - ...(!isProduction && { - devtest: [ - fileURLToPath(new URL('web_src/js/standalone/devtest.ts', import.meta.url)), - fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)), - ], - }), - ...themes, - }, - devtool: false, - output: { - path: fileURLToPath(new URL('public/assets', import.meta.url)), - filename: 'js/[name].js', - chunkFilename: 'js/[name].[contenthash:8].js', - }, - optimization: { - minimize: isProduction, - minimizer: [ - new EsbuildPlugin({ - target: 'es2020', - minify: true, - css: true, - legalComments: 'none', - }), - ], - moduleIds: 'named', - chunkIds: 'named', - }, - module: { - rules: [ - { - test: /\.vue$/i, - exclude: /node_modules/, - loader: 'vue-loader', - options: { - compilerOptions: { - isCustomElement: (tag: string) => webComponents.has(tag), - }, - }, - }, - { - test: /\.js$/i, - exclude: /node_modules/, - use: [ - { - loader: 'esbuild-loader', - options: { - loader: 'js', - target: 'es2020', - }, - }, - ], - }, - { - test: /\.ts$/i, - exclude: /node_modules/, - use: [ - { - loader: 'esbuild-loader', - options: { - loader: 'ts', - target: 'es2020', - }, - }, - ], - }, - { - test: /\.css$/i, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - { - loader: 'css-loader', - options: { - sourceMap: sourceMaps === 'true', - url: {filter: filterCssImport}, - import: {filter: filterCssImport}, - importLoaders: 1, - }, - }, - { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - tailwindcss(tailwindConfig), - ], - }, - }, - }, - ], - }, - { - test: /\.svg$/i, - include: fileURLToPath(new URL('public/assets/img/svg', import.meta.url)), - type: 'asset/source', - }, - { - test: /\.(ttf|woff2?)$/i, - type: 'asset/resource', - generator: { - filename: 'fonts/[name].[contenthash:8][ext]', - }, - }, - ], - }, - plugins: [ - new DefinePlugin({ - __VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API - __VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production - __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // https://github.com/vuejs/vue-cli/pull/7443 - }), - // all environment variables used in bundled js via process.env must be declared here - new EnvironmentPlugin({ - TEST: 'false', - }), - new VueLoaderPlugin(), - new MiniCssExtractPlugin({ - filename: 'css/[name].css', - chunkFilename: 'css/[name].[contenthash:8].css', - }), - sourceMaps !== 'false' && new SourceMapDevToolPlugin({ - filename: '[file].[contenthash:8].map', - ...(sourceMaps === 'reduced' && {include: /^js\/index\.js$/}), - }), - new MonacoWebpackPlugin({ - filename: 'js/monaco-[name].[contenthash:8].worker.js', - }), - isProduction ? new LicenseCheckerWebpackPlugin({ - outputFilename: 'licenses.txt', - outputWriter: ({dependencies}: {dependencies: Array>}) => { - const line = '-'.repeat(80); - const goJson = readFileSync('assets/go-licenses.json', 'utf8'); - const goModules = JSON.parse(goJson).map(({name, licenseText}: Record) => { - return {name, body: formatLicenseText(licenseText)}; - }); - const jsModules = dependencies.map(({name, version, licenseName, licenseText}) => { - return {name, version, licenseName, body: formatLicenseText(licenseText)}; - }); - - const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name)); - return modules.map(({name, version, licenseName, body}) => { - const title = licenseName ? `${name}@${version} - ${licenseName}` : name; - return `${line}\n${title}\n${line}\n${body}`; - }).join('\n'); - }, - override: { - 'khroma@*': {licenseName: 'MIT'}, // https://github.com/fabiospampinato/khroma/pull/33 - }, - emitError: true, - allow: '(Apache-2.0 OR 0BSD OR BSD-2-Clause OR BSD-3-Clause OR MIT OR ISC OR CPAL-1.0 OR Unlicense OR EPL-1.0 OR EPL-2.0)', - }) : new AddAssetPlugin('licenses.txt', `Licenses are disabled during development`), - ], - performance: { - hints: false, - maxEntrypointSize: Infinity, - maxAssetSize: Infinity, - }, - resolve: { - symlinks: true, - modules: ['node_modules'], - }, - watchOptions: { - ignored: [ - 'node_modules/**', - ], - }, - stats: { - assetsSort: 'name', - assetsSpace: Infinity, - cached: false, - cachedModules: false, - children: false, - chunkModules: false, - chunkOrigins: false, - chunksSort: 'name', - colors: true, - entrypoints: false, - groupAssetsByChunk: false, - groupAssetsByEmitStatus: false, - groupAssetsByInfo: false, - groupModulesByAttributes: false, - modules: false, - reasons: false, - runtimeModules: false, - }, -} satisfies Configuration; From 755d200371a5030fac2824085c527ed6a181ae04 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 29 Mar 2026 18:57:39 +0200 Subject: [PATCH 11/39] Update AI Contribution Policy (#37022) I tried to tighten the AI contribution policy and make the expectations around AI-assisted submissions clearer. --------- Signed-off-by: silverwind Co-authored-by: Giteabot Co-authored-by: silverwind --- CONTRIBUTING.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33b329182c7..856515a34e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,16 +70,18 @@ For configuring IDEs for Gitea development, see the [contributed IDE configurati ## AI Contribution Policy -Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly. +Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly and disclose that use clearly. -1. Include related issues or pull requests in the prompt so that the AI has ideal context. -2. Review AI-generated code closely before submitting a pull request. -3. Manually test the changes and add appropriate automated tests where feasible. -4. Only use AI to assist in contributions that you understand well enough to respond to feedback without relying on AI. -5. Indicate AI-generated content in issue and pull requests descriptions and comments. Specify which model was used. -6. Do not use AI to reply to questions about your issue or pull request. The questions are for you, not an AI model. +1. Review AI-generated code closely before marking a pull request ready for review. +2. Manually test the changes and add appropriate automated tests where feasible. +3. Only use AI to assist in contributions that you understand well enough to explain, defend, and revise yourself during review. +4. Disclose AI-assisted content clearly. +5. Do not use AI to reply to questions about your issue or pull request. The questions are for you, not an AI model. +6. AI may be used to help draft issues and pull requests, but contributors remain responsible for the accuracy, completeness, and intent of what they submit. -Maintainers reserve the right to close pull requests and issues that appear to be low-quality AI-generated content. We welcome new contributors, but cannot sustain the effort of supporting contributors who primarily defer to AI rather than engaging substantively with the review process. +Maintainers reserve the right to close pull requests and issues that do not disclose AI assistance, that appear to be low-quality AI-generated content, or where the contributor cannot explain or defend the proposed changes themselves. + +We welcome new contributors, but cannot sustain the effort of supporting contributors who primarily defer to AI rather than engaging substantively with the review process. ## Issues From a88449f13ff08319ea923fb26cf192ea7dbdf16f Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 30 Mar 2026 01:39:15 +0800 Subject: [PATCH 12/39] Fix various problems (#37029) 1. Use "margin/padding inline" * Fix #37027 2. Make DetectWellKnownMimeType fallback to system mime types 3. Make catFileBatchCommunicator close pipes * Old behavior in 1.25: https://github.com/go-gitea/gitea/blob/release/v1.25/modules/git/batch_reader.go#L45-L55 * Try to fix #37028 --- modules/git/catfile_batch_reader.go | 27 +++++++++------ modules/public/mime_types.go | 52 +++++++++++++++++------------ web_src/css/markup/content.css | 25 +++++++------- 3 files changed, 60 insertions(+), 44 deletions(-) diff --git a/modules/git/catfile_batch_reader.go b/modules/git/catfile_batch_reader.go index 8a0b3420795..0c8fc740bee 100644 --- a/modules/git/catfile_batch_reader.go +++ b/modules/git/catfile_batch_reader.go @@ -22,16 +22,16 @@ import ( var catFileBatchDebugWaitClose atomic.Int64 type catFileBatchCommunicator struct { - cancel context.CancelFunc + closeFunc func(err error) reqWriter io.Writer respReader *bufio.Reader debugGitCmd *gitcmd.Command } func (b *catFileBatchCommunicator) Close() { - if b.cancel != nil { - b.cancel() - b.cancel = nil + if b.closeFunc != nil { + b.closeFunc(nil) + b.closeFunc = nil } } @@ -47,10 +47,19 @@ func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Co } stdPipeClose() } + closeFunc := func(err error) { + ctxCancel(err) + pipeClose() + } + return newCatFileBatchWithCloseFunc(ctx, repoPath, cmdCatFile, stdinWriter, stdoutReader, closeFunc) +} - ret = &catFileBatchCommunicator{ +func newCatFileBatchWithCloseFunc(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command, + stdinWriter gitcmd.PipeWriter, stdoutReader gitcmd.PipeReader, closeFunc func(err error), +) *catFileBatchCommunicator { + ret := &catFileBatchCommunicator{ debugGitCmd: cmdCatFile, - cancel: func() { ctxCancel(nil) }, + closeFunc: closeFunc, reqWriter: stdinWriter, respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations } @@ -60,8 +69,7 @@ func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Co log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err) // ideally here it should return the error, but it would require refactoring all callers // so just return a dummy communicator that does nothing, almost the same behavior as before, not bad - ctxCancel(err) - pipeClose() + closeFunc(err) return ret } @@ -70,8 +78,7 @@ func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Co if err != nil && !errors.Is(err, context.Canceled) { log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err) } - ctxCancel(err) - pipeClose() + closeFunc(err) }() return ret diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go index fef85d77cbe..fa4691c6a96 100644 --- a/modules/public/mime_types.go +++ b/modules/public/mime_types.go @@ -4,31 +4,36 @@ package public import ( + "mime" "strings" + "sync" ) -// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType -var wellKnownMimeTypesLower = map[string]string{ - ".avif": "image/avif", - ".css": "text/css; charset=utf-8", - ".gif": "image/gif", - ".htm": "text/html; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".js": "text/javascript; charset=utf-8", - ".json": "application/json", - ".mjs": "text/javascript; charset=utf-8", - ".pdf": "application/pdf", - ".png": "image/png", - ".svg": "image/svg+xml", - ".wasm": "application/wasm", - ".webp": "image/webp", - ".xml": "text/xml; charset=utf-8", +// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, +// see the comment of DetectWellKnownMimeType +var wellKnownMimeTypesLower = sync.OnceValue(func() map[string]string { + return map[string]string{ + ".avif": "image/avif", + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".htm": "text/html; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json", + ".mjs": "text/javascript; charset=utf-8", + ".pdf": "application/pdf", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".webp": "image/webp", + ".xml": "text/xml; charset=utf-8", - // well, there are some types missing from the builtin list - ".txt": "text/plain; charset=utf-8", -} + // well, there are some types missing from the builtin list + ".txt": "text/plain; charset=utf-8", + } +}) // DetectWellKnownMimeType will return the mime-type for a well-known file ext name // The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension @@ -38,5 +43,8 @@ var wellKnownMimeTypesLower = map[string]string{ // DetectWellKnownMimeType makes the Content-Type for well-known files stable. func DetectWellKnownMimeType(ext string) string { ext = strings.ToLower(ext) - return wellKnownMimeTypesLower[ext] + if s, ok := wellKnownMimeTypesLower()[ext]; ok { + return s + } + return mime.TypeByExtension(ext) } diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index 6ca6f95c695..c86510d5cf8 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -24,8 +24,8 @@ .markup .anchor { float: left; - padding-right: 4px; - margin-left: -20px; + padding-inline-end: 4px; + margin-inline-start: -20px; color: inherit; } @@ -151,7 +151,7 @@ In markup content, we always use bottom margin for all elements */ .markup ul, .markup ol { - padding-left: 2em; + padding-inline-start: 2em; } .markup ul.no-list, @@ -173,13 +173,14 @@ In markup content, we always use bottom margin for all elements */ } .markup .task-list-item input[type="checkbox"] { - margin: 0 .6em .25em -1.4em; + margin-bottom: 0.25em; + margin-inline: -1.4em 0.6em; vertical-align: middle; padding: 0; } .markup .task-list-item input[type="checkbox"] + p { - margin-left: -0.2em; + margin-inline-start: -0.2em; display: inline; } @@ -192,7 +193,7 @@ In markup content, we always use bottom margin for all elements */ } .markup input[type="checkbox"] { - margin-right: .25em; + margin-inline-end: .25em; margin-bottom: .25em; cursor: default; opacity: 1 !important; /* override fomantic on edit preview */ @@ -239,7 +240,7 @@ In markup content, we always use bottom margin for all elements */ } .markup blockquote { - margin-left: 0; + margin-inline-start: 0; padding: 0 15px; color: var(--color-text-light-2); border-left: 0.25em solid var(--color-secondary); @@ -318,12 +319,12 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .markup img[align="right"], .markup video[align="right"] { - padding-left: 20px; + padding-inline-start: 20px; } .markup img[align="left"], .markup video[align="left"] { - padding-right: 28px; + padding-inline-end: 28px; } .markup span.frame { @@ -395,7 +396,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .markup span.float-left { display: block; float: left; - margin-right: 13px; + margin-inline-end: 13px; overflow: hidden; } @@ -406,7 +407,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .markup span.float-right { display: block; float: right; - margin-left: 13px; + margin-inline-start: 13px; overflow: hidden; } @@ -508,7 +509,7 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { .markup .ui.list .list, .markup ol.ui.list ol, .markup ul.ui.list ul { - padding-left: 2em; + padding-inline-start: 2em; } .markup details.frontmatter-content summary { From da51d5af1a49bf654fc5952083875a624086da32 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 29 Mar 2026 20:12:46 +0200 Subject: [PATCH 13/39] Add support for in_progress event in workflow_run webhook (#36979) With Gitea 1.25.4 the workflow event for in_progress was not triggered for Gitea Actions. Fixes #36906 --------- Co-authored-by: Claude Sonnet 4.6 --- services/actions/task.go | 5 ++ tests/integration/repo_webhook_test.go | 104 ++++++++++++++++--------- 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/services/actions/task.go b/services/actions/task.go index a21b6009987..2cb10b6cd8f 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -103,6 +103,11 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv CreateCommitStatusForRunJobs(ctx, job.Run, job) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask) + // job.Run is loaded inside the transaction before UpdateRunJob sets run.Started, + // so Started is zero only on the very first pick-up of that run. + if job.Run.Started.IsZero() { + NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + } return task, true, nil } diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 9ac9cced703..4b72962d4f5 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -1401,7 +1401,10 @@ jobs: assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name) assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName) + runID := webhookData.payloads[0].WorkflowRun.ID + // The first runner to pick up a task fires in_progress (Started.IsZero() is true only once per run). + // The second runner picking up an independent job does not fire another in_progress event. for _, runner := range runners { task := runner.fetchTask(t) runner.execTask(t, task, &mockTaskOutcome{ @@ -1411,38 +1414,51 @@ jobs: // Call cancel ui api // Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. - cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.ID) + cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", runID) req := NewRequest(t, "POST", cancelURL) session.MakeRequest(t, req, http.StatusOK) - assert.Len(t, webhookData.payloads, 2) + assert.Len(t, webhookData.payloads, 3) - // 4. Validate the second webhook payload + // 4. Validate the second webhook payload (in_progress, fired when the first runner picked up a job) assert.Equal(t, "workflow_run", webhookData.triggeredEvent) - assert.Equal(t, "completed", webhookData.payloads[1].Action) + assert.Equal(t, "in_progress", webhookData.payloads[1].Action) + assert.Equal(t, "in_progress", webhookData.payloads[1].WorkflowRun.Status) assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event) - assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) + assert.Equal(t, runID, webhookData.payloads[1].WorkflowRun.ID) assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name) assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName) - // Call rerun ui api - // Only a web UI API exists for rerunning workflow runs, so use the UI endpoint. - rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", webhookData.payloads[0].WorkflowRun.ID) - req = NewRequest(t, "POST", rerunURL) - session.MakeRequest(t, req, http.StatusOK) - - assert.Len(t, webhookData.payloads, 3) - - // 5. Validate the third webhook payload + // 5. Validate the third webhook payload (completed, fired after cancel) assert.Equal(t, "workflow_run", webhookData.triggeredEvent) - assert.Equal(t, "requested", webhookData.payloads[2].Action) - assert.Equal(t, "queued", webhookData.payloads[2].WorkflowRun.Status) + assert.Equal(t, "completed", webhookData.payloads[2].Action) + assert.Equal(t, "push", webhookData.payloads[2].WorkflowRun.Event) + assert.Equal(t, "completed", webhookData.payloads[2].WorkflowRun.Status) + assert.Equal(t, runID, webhookData.payloads[2].WorkflowRun.ID) assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch) assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha) assert.Equal(t, "repo1", webhookData.payloads[2].Repo.Name) assert.Equal(t, "user2/repo1", webhookData.payloads[2].Repo.FullName) + + // Call rerun ui api + // Only a web UI API exists for rerunning workflow runs, so use the UI endpoint. + rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", runID) + req = NewRequest(t, "POST", rerunURL) + session.MakeRequest(t, req, http.StatusOK) + + assert.Len(t, webhookData.payloads, 4) + + // 6. Validate the fourth webhook payload (requested, fired after rerun) + assert.Equal(t, "workflow_run", webhookData.triggeredEvent) + assert.Equal(t, "requested", webhookData.payloads[3].Action) + assert.Equal(t, "queued", webhookData.payloads[3].WorkflowRun.Status) + assert.Equal(t, "push", webhookData.payloads[3].WorkflowRun.Event) + assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[3].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[3].WorkflowRun.HeadSha) + assert.Equal(t, "repo1", webhookData.payloads[3].Repo.Name) + assert.Equal(t, "user2/repo1", webhookData.payloads[3].Repo.FullName) } func testWorkflowRunEventsOnCancellingAbandonedRun(t *testing.T, webhookData *workflowRunWebhook, allJobsAbandoned bool) { @@ -1572,13 +1588,28 @@ jobs: err = actions.CancelAbandonedJobs(ctx) assert.NoError(t, err) - assert.Len(t, webhookData.payloads, 2) - assert.Equal(t, "completed", webhookData.payloads[1].Action) - assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) - assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) - assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) - assert.Equal(t, repoName, webhookData.payloads[1].Repo.Name) - assert.Equal(t, "user2/"+repoName, webhookData.payloads[1].Repo.FullName) + + if allJobsAbandoned { + // No runner picked up any task, so no in_progress event was fired. + assert.Len(t, webhookData.payloads, 2) + assert.Equal(t, "completed", webhookData.payloads[1].Action) + assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) + assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) + assert.Equal(t, repoName, webhookData.payloads[1].Repo.Name) + assert.Equal(t, "user2/"+repoName, webhookData.payloads[1].Repo.FullName) + } else { + // The first runner pick-up fired in_progress before the run was abandoned. + assert.Len(t, webhookData.payloads, 3) + assert.Equal(t, "in_progress", webhookData.payloads[1].Action) + assert.Equal(t, "in_progress", webhookData.payloads[1].WorkflowRun.Status) + assert.Equal(t, "completed", webhookData.payloads[2].Action) + assert.Equal(t, "completed", webhookData.payloads[2].WorkflowRun.Status) + assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha) + assert.Equal(t, repoName, webhookData.payloads[2].Repo.Name) + assert.Equal(t, "user2/"+repoName, webhookData.payloads[2].Repo.FullName) + } } func testWorkflowRunOnStoppingEndlessTasksForMultipleRuns(t *testing.T, webhookData *workflowRunWebhook) { @@ -1741,20 +1772,23 @@ jobs: // 7. validate the webhook is triggered assert.Equal(t, "workflow_run", webhookData.triggeredEvent) - assert.Len(t, webhookData.payloads, 3) - assert.Equal(t, "completed", webhookData.payloads[1].Action) + assert.Len(t, webhookData.payloads, 4) + // payloads[1] is the in_progress event fired when the runner picked up wf1-job + assert.Equal(t, "in_progress", webhookData.payloads[1].Action) + assert.Equal(t, "in_progress", webhookData.payloads[1].WorkflowRun.Status) assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event) + assert.Equal(t, "completed", webhookData.payloads[2].Action) + assert.Equal(t, "push", webhookData.payloads[2].WorkflowRun.Event) - // 3. validate the webhook is triggered - assert.Equal(t, "workflow_run", webhookData.triggeredEvent) - assert.Len(t, webhookData.payloads, 3) - assert.Equal(t, "requested", webhookData.payloads[2].Action) - assert.Equal(t, "queued", webhookData.payloads[2].WorkflowRun.Status) - assert.Equal(t, "workflow_run", webhookData.payloads[2].WorkflowRun.Event) - assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch) - assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha) - assert.Equal(t, "repo1", webhookData.payloads[2].Repo.Name) - assert.Equal(t, "user2/repo1", webhookData.payloads[2].Repo.FullName) + // 8. validate the webhook is triggered (requested, wf2 triggered by wf1 completion) + assert.Len(t, webhookData.payloads, 4) + assert.Equal(t, "requested", webhookData.payloads[3].Action) + assert.Equal(t, "queued", webhookData.payloads[3].WorkflowRun.Status) + assert.Equal(t, "workflow_run", webhookData.payloads[3].WorkflowRun.Event) + assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[3].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[3].WorkflowRun.HeadSha) + assert.Equal(t, "repo1", webhookData.payloads[3].Repo.Name) + assert.Equal(t, "user2/repo1", webhookData.payloads[3].Repo.FullName) } func testWebhookWorkflowRunDepthLimit(t *testing.T, webhookData *workflowRunWebhook) { From 50a1dc9486fb039a9408fc2ff5efb3bd72629d7e Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 29 Mar 2026 20:48:40 +0200 Subject: [PATCH 14/39] Make task list checkboxes clickable in the preview tab (#37010) When a checkbox is toggled in the markup preview tab, the change is now synced back to the editor textarea. Extracted a `toggleTasklistCheckbox` helper to deduplicate the byte-offset toggle logic. --------- Co-authored-by: Claude (Opus 4.6) --- .../js/features/comp/ComboMarkdownEditor.ts | 15 ++++++++++ web_src/js/markup/tasklist.test.ts | 9 ++++++ web_src/js/markup/tasklist.ts | 30 ++++++++++++------- 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 web_src/js/markup/tasklist.test.ts diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 468f3fc5ca6..f16a71a6c57 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -10,6 +10,7 @@ import { } from './EditorUpload.ts'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts'; import {renderPreviewPanelContent} from '../repo-editor.ts'; +import {toggleTasklistCheckbox} from '../../markup/tasklist.ts'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts'; import {initTextExpander} from './TextExpander.ts'; import {showErrorToast} from '../../modules/toast.ts'; @@ -236,6 +237,20 @@ export class ComboMarkdownEditor { const response = await POST(this.previewUrl, {data: formData}); const data = await response.text(); renderPreviewPanelContent(panelPreviewer, data); + // enable task list checkboxes in preview and sync state back to the editor + for (const checkbox of panelPreviewer.querySelectorAll('.task-list-item input[type=checkbox]')) { + checkbox.disabled = false; + checkbox.addEventListener('input', () => { + const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1; + const newContent = toggleTasklistCheckbox(this.value(), position, checkbox.checked); + if (newContent === null) { + checkbox.checked = !checkbox.checked; + return; + } + this.value(newContent); + triggerEditorContentChanged(this.container); + }); + } }); } diff --git a/web_src/js/markup/tasklist.test.ts b/web_src/js/markup/tasklist.test.ts new file mode 100644 index 00000000000..ec5eceebd07 --- /dev/null +++ b/web_src/js/markup/tasklist.test.ts @@ -0,0 +1,9 @@ +import {toggleTasklistCheckbox} from './tasklist.ts'; + +test('toggleTasklistCheckbox', () => { + expect(toggleTasklistCheckbox('- [ ] task', 3, true)).toEqual('- [x] task'); + expect(toggleTasklistCheckbox('- [x] task', 3, false)).toEqual('- [ ] task'); + expect(toggleTasklistCheckbox('- [ ] task', 0, true)).toBeNull(); + expect(toggleTasklistCheckbox('- [ ] task', 99, true)).toBeNull(); + expect(toggleTasklistCheckbox('😀 - [ ] task', 8, true)).toEqual('😀 - [x] task'); +}); diff --git a/web_src/js/markup/tasklist.ts b/web_src/js/markup/tasklist.ts index 7f3417c2bb5..557afeaea58 100644 --- a/web_src/js/markup/tasklist.ts +++ b/web_src/js/markup/tasklist.ts @@ -3,6 +3,23 @@ import {showErrorToast} from '../modules/toast.ts'; const preventListener = (e: Event) => e.preventDefault(); +/** + * Toggle a task list checkbox in markdown content. + * `position` is the byte offset of the space or `x` character inside `[ ]`. + * Returns the updated content, or null if the position is invalid. + */ +export function toggleTasklistCheckbox(content: string, position: number, checked: boolean): string | null { + const buffer = new TextEncoder().encode(content); + // Indexes may fall off the ends and return undefined. + if (buffer[position - 1] !== '['.charCodeAt(0) || + buffer[position] !== ' '.charCodeAt(0) && buffer[position] !== 'x'.charCodeAt(0) || + buffer[position + 1] !== ']'.charCodeAt(0)) { + return null; + } + buffer[position] = checked ? 'x'.charCodeAt(0) : ' '.charCodeAt(0); + return new TextDecoder().decode(buffer); +} + /** * Attaches `input` handlers to markdown rendered tasklist checkboxes in comments. * @@ -23,24 +40,17 @@ export function initMarkupTasklist(elMarkup: HTMLElement): void { checkbox.setAttribute('data-editable', 'true'); checkbox.addEventListener('input', async () => { - const checkboxCharacter = checkbox.checked ? 'x' : ' '; const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1; const rawContent = container.querySelector('.raw-content')!; const oldContent = rawContent.textContent; - const encoder = new TextEncoder(); - const buffer = encoder.encode(oldContent); - // Indexes may fall off the ends and return undefined. - if (buffer[position - 1] !== '['.codePointAt(0) || - buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) || - buffer[position + 1] !== ']'.codePointAt(0)) { - // Position is probably wrong. Revert and don't allow change. + const newContent = toggleTasklistCheckbox(oldContent, position, checkbox.checked); + if (newContent === null) { + // Position is probably wrong. Revert and don't allow change. checkbox.checked = !checkbox.checked; throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`); } - buffer.set(encoder.encode(checkboxCharacter), position); - const newContent = new TextDecoder().decode(buffer); if (newContent === oldContent) { return; From d7070b851389e97fc5ba104f2efe4ec6a2293264 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Sun, 29 Mar 2026 17:02:15 -0400 Subject: [PATCH 15/39] Bump go and python versions in nix flake (#37031) --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 6fb38919638..7b9fbb193c3 100644 --- a/flake.nix +++ b/flake.nix @@ -33,9 +33,9 @@ inherit (pkgs) lib; # only bump toolchain versions here - go = pkgs.go_1_25; + go = pkgs.go_1_26; nodejs = pkgs.nodejs_24; - python3 = pkgs.python312; + python3 = pkgs.python314; pnpm = pkgs.pnpm_10; # Platform-specific dependencies From cbea04c1fc1af7e9f35303b057dc8f222ac03f08 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:25:18 -0400 Subject: [PATCH 16/39] Update Nix flake (#37024) --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 8c7ac0c1960..246cfd4e797 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1773821835, - "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "lastModified": 1774386573, + "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", "type": "github" }, "original": { From 2633f9677d1f313b04c30793b4376ff6614109cc Mon Sep 17 00:00:00 2001 From: Myers Carpenter Date: Sun, 29 Mar 2026 20:28:48 -0400 Subject: [PATCH 17/39] Correct swagger annotations for enums, status codes, and notification state (#37030) ## :warning: BREAKING :warning: - delete reaction endpoints is changed to return 204 No Content rather than 200 with no content. ## Summary Add swagger:enum annotations and migrate all enum comments from the deprecated comma-separated format to JSON arrays. Introduce NotifySubjectStateType with open/closed/merged values. Fix delete reaction endpoints to return 204 instead of 200. --- modules/structs/activity.go | 2 +- modules/structs/hook.go | 2 +- modules/structs/issue.go | 26 +-- modules/structs/issue_milestone.go | 3 +- modules/structs/notifications.go | 17 +- modules/structs/org.go | 4 +- modules/structs/org_team.go | 6 +- modules/structs/pull_review.go | 7 +- modules/structs/repo.go | 8 +- modules/structs/repo_collaborator.go | 2 +- modules/structs/repo_file.go | 2 +- routers/api/v1/admin/runners.go | 6 +- routers/api/v1/api.go | 2 - routers/api/v1/org/action.go | 6 +- routers/api/v1/repo/action.go | 8 +- routers/api/v1/repo/issue_reaction.go | 10 +- routers/api/v1/repo/pull.go | 2 + routers/api/v1/user/runners.go | 6 +- services/convert/notification.go | 6 +- services/convert/notification_test.go | 75 +++++++++ services/convert/status.go | 3 + services/forms/repo_form.go | 2 +- templates/swagger/v1_json.tmpl | 166 +++++++++++++------ tests/integration/api_issue_reaction_test.go | 4 +- 24 files changed, 265 insertions(+), 110 deletions(-) diff --git a/modules/structs/activity.go b/modules/structs/activity.go index 9085495593a..b896adfed52 100644 --- a/modules/structs/activity.go +++ b/modules/structs/activity.go @@ -12,7 +12,7 @@ type Activity struct { UserID int64 `json:"user_id"` // Receiver user // the type of action // - // enum: create_repo,rename_repo,star_repo,watch_repo,commit_repo,create_issue,create_pull_request,transfer_repo,push_tag,comment_issue,merge_pull_request,close_issue,reopen_issue,close_pull_request,reopen_pull_request,delete_tag,delete_branch,mirror_sync_push,mirror_sync_create,mirror_sync_delete,approve_pull_request,reject_pull_request,comment_pull,publish_release,pull_review_dismissed,pull_request_ready_for_review,auto_merge_pull_request + // enum: ["create_repo","rename_repo","star_repo","watch_repo","commit_repo","create_issue","create_pull_request","transfer_repo","push_tag","comment_issue","merge_pull_request","close_issue","reopen_issue","close_pull_request","reopen_pull_request","delete_tag","delete_branch","mirror_sync_push","mirror_sync_create","mirror_sync_delete","approve_pull_request","reject_pull_request","comment_pull","publish_release","pull_review_dismissed","pull_request_ready_for_review","auto_merge_pull_request"] OpType string `json:"op_type"` // The ID of the user who performed the action ActUserID int64 `json:"act_user_id"` diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 57af38464a2..931589696a6 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -51,7 +51,7 @@ type CreateHookOptionConfig map[string]string // CreateHookOption options when create a hook type CreateHookOption struct { // required: true - // enum: dingtalk,discord,gitea,gogs,msteams,slack,telegram,feishu,wechatwork,packagist + // enum: ["dingtalk","discord","gitea","gogs","msteams","slack","telegram","feishu","wechatwork","packagist"] // The type of the webhook to create Type string `json:"type" binding:"Required"` // required: true diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 2540481d0ff..1efe3334ca3 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -14,6 +14,8 @@ import ( ) // StateType issue state type +// +// swagger:enum StateType type StateType string const ( @@ -21,10 +23,11 @@ const ( StateOpen StateType = "open" // StateClosed pr is closed StateClosed StateType = "closed" - // StateAll is all - StateAll StateType = "all" ) +// StateAll is a query parameter filter value, not a valid object state. +const StateAll = "all" + // PullRequestMeta PR info if an issue is a PR type PullRequestMeta struct { HasMerged bool `json:"merged"` @@ -58,15 +61,11 @@ type Issue struct { Labels []*Label `json:"labels"` Milestone *Milestone `json:"milestone"` // deprecated - Assignee *User `json:"assignee"` - Assignees []*User `json:"assignees"` - // Whether the issue is open or closed - // - // type: string - // enum: open,closed - State StateType `json:"state"` - IsLocked bool `json:"is_locked"` - Comments int `json:"comments"` + Assignee *User `json:"assignee"` + Assignees []*User `json:"assignees"` + State StateType `json:"state"` + IsLocked bool `json:"is_locked"` + Comments int `json:"comments"` // swagger:strfmt date-time Created time.Time `json:"created_at"` // swagger:strfmt date-time @@ -132,6 +131,8 @@ type IssueDeadline struct { } // IssueFormFieldType defines issue form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes" +// +// swagger:enum IssueFormFieldType type IssueFormFieldType string const ( @@ -168,7 +169,8 @@ func (iff IssueFormField) VisibleInContent() bool { } // IssueFormFieldVisible defines issue form field visible -// swagger:model +// +// swagger:enum IssueFormFieldVisible type IssueFormFieldVisible string const ( diff --git a/modules/structs/issue_milestone.go b/modules/structs/issue_milestone.go index 226c613d47b..dd8bdc6cda7 100644 --- a/modules/structs/issue_milestone.go +++ b/modules/structs/issue_milestone.go @@ -40,7 +40,7 @@ type CreateMilestoneOption struct { // swagger:strfmt date-time // Deadline is the due date for the milestone Deadline *time.Time `json:"due_on"` - // enum: open,closed + // enum: ["open","closed"] // State indicates the initial state of the milestone State string `json:"state"` } @@ -52,6 +52,7 @@ type EditMilestoneOption struct { // Description provides updated details about the milestone Description *string `json:"description"` // State indicates the updated state of the milestone + // enum: ["open","closed"] State *string `json:"state"` // Deadline is the updated due date for the milestone Deadline *time.Time `json:"due_on"` diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go index cee5da6624d..d7aa0783dc2 100644 --- a/modules/structs/notifications.go +++ b/modules/structs/notifications.go @@ -40,7 +40,7 @@ type NotificationSubject struct { // Type indicates the type of the notification subject Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository)"` // State indicates the current state of the notification subject - State StateType `json:"state"` + State NotifySubjectStateType `json:"state"` } // NotificationCount number of unread notifications @@ -49,7 +49,22 @@ type NotificationCount struct { New int64 `json:"new"` } +// NotifySubjectStateType represents the state of a notification subject +// swagger:enum NotifySubjectStateType +type NotifySubjectStateType string + +const ( + // NotifySubjectStateOpen is an open subject + NotifySubjectStateOpen NotifySubjectStateType = "open" + // NotifySubjectStateClosed is a closed subject + NotifySubjectStateClosed NotifySubjectStateType = "closed" + // NotifySubjectStateMerged is a merged pull request + NotifySubjectStateMerged NotifySubjectStateType = "merged" +) + // NotifySubjectType represent type of notification subject +// +// swagger:enum NotifySubjectType type NotifySubjectType string const ( diff --git a/modules/structs/org.go b/modules/structs/org.go index d79b1d1d1c0..723689cb53a 100644 --- a/modules/structs/org.go +++ b/modules/structs/org.go @@ -60,7 +60,7 @@ type CreateOrgOption struct { // The location of the organization Location string `json:"location" binding:"MaxSize(50)"` // possible values are `public` (default), `limited` or `private` - // enum: public,limited,private + // enum: ["public","limited","private"] Visibility string `json:"visibility" binding:"In(,public,limited,private)"` // Whether repository administrators can change team access RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"` @@ -79,7 +79,7 @@ type EditOrgOption struct { // The location of the organization Location *string `json:"location" binding:"MaxSize(50)"` // possible values are `public`, `limited` or `private` - // enum: public,limited,private + // enum: ["public","limited","private"] Visibility *string `json:"visibility" binding:"In(,public,limited,private)"` // Whether repository administrators can change team access RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"` diff --git a/modules/structs/org_team.go b/modules/structs/org_team.go index d34de5b6d2e..f730a5681c8 100644 --- a/modules/structs/org_team.go +++ b/modules/structs/org_team.go @@ -16,7 +16,7 @@ type Team struct { Organization *Organization `json:"organization"` // Whether the team has access to all repositories in the organization IncludesAllRepositories bool `json:"includes_all_repositories"` - // enum: none,read,write,admin,owner + // enum: ["none","read","write","admin","owner"] Permission string `json:"permission"` // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"] // Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions. @@ -35,7 +35,7 @@ type CreateTeamOption struct { Description string `json:"description" binding:"MaxSize(255)"` // Whether the team has access to all repositories in the organization IncludesAllRepositories bool `json:"includes_all_repositories"` - // enum: read,write,admin + // enum: ["read","write","admin"] Permission string `json:"permission"` // example: ["repo.actions","repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.ext_wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"] // Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions. @@ -54,7 +54,7 @@ type EditTeamOption struct { Description *string `json:"description" binding:"MaxSize(255)"` // Whether the team has access to all repositories in the organization IncludesAllRepositories *bool `json:"includes_all_repositories"` - // enum: read,write,admin + // enum: ["read","write","admin"] Permission string `json:"permission"` // example: ["repo.code","repo.issues","repo.ext_issues","repo.wiki","repo.pulls","repo.releases","repo.projects","repo.ext_wiki"] // Deprecated: This variable should be replaced by UnitsMap and will be dropped in later versions. diff --git a/modules/structs/pull_review.go b/modules/structs/pull_review.go index f44d2f84f5a..de0677efabd 100644 --- a/modules/structs/pull_review.go +++ b/modules/structs/pull_review.go @@ -8,6 +8,8 @@ import ( ) // ReviewStateType review state type +// +// swagger:enum ReviewStateType type ReviewStateType string const ( @@ -21,10 +23,11 @@ const ( ReviewStateRequestChanges ReviewStateType = "REQUEST_CHANGES" // ReviewStateRequestReview review is requested from user ReviewStateRequestReview ReviewStateType = "REQUEST_REVIEW" - // ReviewStateUnknown state of pr is unknown - ReviewStateUnknown ReviewStateType = "" ) +// ReviewStateUnknown is an internal sentinel for unknown review state, not a valid API value. +const ReviewStateUnknown = "" + // PullReview represents a pull request review type PullReview struct { ID int64 `json:"id"` diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 3507cc410a1..7cd64fd7a4d 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -114,7 +114,7 @@ type Repository struct { Internal bool `json:"internal"` MirrorInterval string `json:"mirror_interval"` // ObjectFormatName of the underlying git repository - // enum: sha1,sha256 + // enum: ["sha1","sha256"] ObjectFormatName string `json:"object_format_name"` // swagger:strfmt date-time MirrorUpdated time.Time `json:"mirror_updated"` @@ -150,10 +150,10 @@ type CreateRepoOption struct { // DefaultBranch of the repository (used when initializes and in template) DefaultBranch string `json:"default_branch" binding:"GitRefName;MaxSize(100)"` // TrustModel of the repository - // enum: default,collaborator,committer,collaboratorcommitter + // enum: ["default","collaborator","committer","collaboratorcommitter"] TrustModel string `json:"trust_model"` // ObjectFormatName of the underlying git repository, empty string for default (sha1) - // enum: sha1,sha256 + // enum: ["sha1","sha256"] ObjectFormatName string `json:"object_format_name" binding:"MaxSize(6)"` } @@ -378,7 +378,7 @@ type MigrateRepoOptions struct { // required: true RepoName string `json:"repo_name" binding:"Required;AlphaDashDot;MaxSize(100)"` - // enum: git,github,gitea,gitlab,gogs,onedev,gitbucket,codebase,codecommit + // enum: ["git","github","gitea","gitlab","gogs","onedev","gitbucket","codebase","codecommit"] Service string `json:"service"` AuthUsername string `json:"auth_username"` AuthPassword string `json:"auth_password"` diff --git a/modules/structs/repo_collaborator.go b/modules/structs/repo_collaborator.go index 9ede7f075a6..6b315df403a 100644 --- a/modules/structs/repo_collaborator.go +++ b/modules/structs/repo_collaborator.go @@ -5,7 +5,7 @@ package structs // AddCollaboratorOption options when adding a user as a collaborator of a repository type AddCollaboratorOption struct { - // enum: read,write,admin + // enum: ["read","write","admin"] // Permission level to grant the collaborator Permission *string `json:"permission"` } diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 59665062b77..53ce5aeae28 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -72,7 +72,7 @@ type ChangeFileOperation struct { // indicates what to do with the file: "create" for creating a new file, "update" for updating an existing file, // "upload" for creating or updating a file, "rename" for renaming a file, and "delete" for deleting an existing file. // required: true - // enum: create,update,upload,rename,delete + // enum: ["create","update","upload","rename","delete"] Operation string `json:"operation" binding:"Required"` // path to the existing or new file // required: true diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go index 93983f6c7e2..3d27c87935a 100644 --- a/routers/api/v1/admin/runners.go +++ b/routers/api/v1/admin/runners.go @@ -40,7 +40,7 @@ func ListRunners(ctx *context.APIContext) { // required: false // responses: // "200": - // "$ref": "#/definitions/ActionRunnersResponse" + // "$ref": "#/responses/RunnerList" // "400": // "$ref": "#/responses/error" // "404": @@ -63,7 +63,7 @@ func GetRunner(ctx *context.APIContext) { // required: true // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": @@ -115,7 +115,7 @@ func UpdateRunner(ctx *context.APIContext) { // "$ref": "#/definitions/EditActionRunnerOption" // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ea595407d11..e1d836b5c85 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -11,11 +11,9 @@ // // Consumes: // - application/json -// - text/plain // // Produces: // - application/json -// - text/html // // Security: // - BasicAuth : diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 18ed602ddbb..01b57b3fac9 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -492,7 +492,7 @@ func (Action) ListRunners(ctx *context.APIContext) { // required: false // responses: // "200": - // "$ref": "#/definitions/ActionRunnersResponse" + // "$ref": "#/responses/RunnerList" // "400": // "$ref": "#/responses/error" // "404": @@ -520,7 +520,7 @@ func (Action) GetRunner(ctx *context.APIContext) { // required: true // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": @@ -582,7 +582,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) { // "$ref": "#/definitions/EditActionRunnerOption" // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 0c48f732abf..7ac8a10575c 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -561,7 +561,7 @@ func (Action) ListRunners(ctx *context.APIContext) { // required: false // responses: // "200": - // "$ref": "#/definitions/ActionRunnersResponse" + // "$ref": "#/responses/RunnerList" // "400": // "$ref": "#/responses/error" // "404": @@ -594,7 +594,7 @@ func (Action) GetRunner(ctx *context.APIContext) { // required: true // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": @@ -666,7 +666,7 @@ func (Action) UpdateRunner(ctx *context.APIContext) { // "$ref": "#/definitions/EditActionRunnerOption" // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": @@ -1192,7 +1192,7 @@ func GetWorkflowRun(ctx *context.APIContext) { // - name: run // in: path // description: id of the run - // type: string + // type: integer // required: true // responses: // "200": diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index e535b5e0091..1f313acde8c 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -175,7 +175,7 @@ func DeleteIssueCommentReaction(ctx *context.APIContext) { // schema: // "$ref": "#/definitions/EditReactionOption" // responses: - // "200": + // "204": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" @@ -248,8 +248,7 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp ctx.APIErrorInternal(err) return } - // ToDo respond 204 - ctx.Status(http.StatusOK) + ctx.Status(http.StatusNoContent) } } @@ -408,7 +407,7 @@ func DeleteIssueReaction(ctx *context.APIContext) { // schema: // "$ref": "#/definitions/EditReactionOption" // responses: - // "200": + // "204": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" @@ -464,7 +463,6 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i ctx.APIErrorInternal(err) return } - // ToDo respond 204 - ctx.Status(http.StatusOK) + ctx.Status(http.StatusNoContent) } } diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index f405a3152f7..a045bba49cc 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -898,6 +898,8 @@ func MergePullRequest(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "405": diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go index 667bdb36fee..e06b022f356 100644 --- a/routers/api/v1/user/runners.go +++ b/routers/api/v1/user/runners.go @@ -40,7 +40,7 @@ func ListRunners(ctx *context.APIContext) { // required: false // responses: // "200": - // "$ref": "#/definitions/ActionRunnersResponse" + // "$ref": "#/responses/RunnerList" // "400": // "$ref": "#/responses/error" // "404": @@ -63,7 +63,7 @@ func GetRunner(ctx *context.APIContext) { // required: true // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": @@ -115,7 +115,7 @@ func UpdateRunner(ctx *context.APIContext) { // "$ref": "#/definitions/EditActionRunnerOption" // responses: // "200": - // "$ref": "#/definitions/ActionRunner" + // "$ref": "#/responses/Runner" // "400": // "$ref": "#/responses/error" // "404": diff --git a/services/convert/notification.go b/services/convert/notification.go index e91bc7dcde4..3a1ae09dc5b 100644 --- a/services/convert/notification.go +++ b/services/convert/notification.go @@ -47,7 +47,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification) result.Subject.Title = n.Issue.Title result.Subject.URL = n.Issue.APIURL(ctx) result.Subject.HTMLURL = n.Issue.HTMLURL(ctx) - result.Subject.State = n.Issue.State() + result.Subject.State = api.NotifySubjectStateType(n.Issue.State()) comment, err := n.Issue.GetLastComment(ctx) if err == nil && comment != nil { result.Subject.LatestCommentURL = comment.APIURL(ctx) @@ -60,7 +60,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification) result.Subject.Title = n.Issue.Title result.Subject.URL = n.Issue.APIURL(ctx) result.Subject.HTMLURL = n.Issue.HTMLURL(ctx) - result.Subject.State = n.Issue.State() + result.Subject.State = api.NotifySubjectStateType(n.Issue.State()) comment, err := n.Issue.GetLastComment(ctx) if err == nil && comment != nil { result.Subject.LatestCommentURL = comment.APIURL(ctx) @@ -70,7 +70,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification) if err := n.Issue.LoadPullRequest(ctx); err == nil && n.Issue.PullRequest != nil && n.Issue.PullRequest.HasMerged { - result.Subject.State = "merged" + result.Subject.State = api.NotifySubjectStateMerged } } case activities_model.NotificationSourceCommit: diff --git a/services/convert/notification_test.go b/services/convert/notification_test.go index 718a0708198..0a4f9d6c0a0 100644 --- a/services/convert/notification_test.go +++ b/services/convert/notification_test.go @@ -7,12 +7,15 @@ import ( "testing" activities_model "code.gitea.io/gitea/models/activities" + issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestToNotificationThreadIncludesRepoForAccessibleUser(t *testing.T) { @@ -36,6 +39,78 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) { assert.Nil(t, thread.Repository) } +func TestToNotificationThread(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("issue notification", func(t *testing.T) { + // Notification 1: source=issue, issue_id=1, status=unread + n := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 1}) + require.NoError(t, n.LoadAttributes(t.Context())) + + thread := ToNotificationThread(t.Context(), n) + assert.Equal(t, int64(1), thread.ID) + assert.True(t, thread.Unread) + assert.False(t, thread.Pinned) + require.NotNil(t, thread.Subject) + assert.Equal(t, api.NotifySubjectIssue, thread.Subject.Type) + assert.Equal(t, api.NotifySubjectStateOpen, thread.Subject.State) + }) + + t.Run("pinned notification", func(t *testing.T) { + // Notification 3: status=pinned + n := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 3}) + require.NoError(t, n.LoadAttributes(t.Context())) + + thread := ToNotificationThread(t.Context(), n) + assert.False(t, thread.Unread) + assert.True(t, thread.Pinned) + }) + + t.Run("merged pull request returns merged state", func(t *testing.T) { + // Issue 2 is a pull request; pull_request 1 has has_merged=true. + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + + n := &activities_model.Notification{ + ID: 999, + UserID: 2, + RepoID: repo.ID, + Status: activities_model.NotificationStatusUnread, + Source: activities_model.NotificationSourcePullRequest, + IssueID: issue.ID, + Issue: issue, + Repository: repo, + } + + thread := ToNotificationThread(t.Context(), n) + require.NotNil(t, thread.Subject) + assert.Equal(t, api.NotifySubjectPull, thread.Subject.Type) + assert.Equal(t, api.NotifySubjectStateMerged, thread.Subject.State) + }) + + t.Run("open pull request returns open state", func(t *testing.T) { + // Issue 3 is a pull request; pull_request 2 has has_merged=false. + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + + n := &activities_model.Notification{ + ID: 998, + UserID: 2, + RepoID: repo.ID, + Status: activities_model.NotificationStatusUnread, + Source: activities_model.NotificationSourcePullRequest, + IssueID: issue.ID, + Issue: issue, + Repository: repo, + } + + thread := ToNotificationThread(t.Context(), n) + require.NotNil(t, thread.Subject) + assert.Equal(t, api.NotifySubjectPull, thread.Subject.Type) + assert.Equal(t, api.NotifySubjectStateOpen, thread.Subject.State) + }) +} + func newRepoNotification(t *testing.T, repoID, userID int64) *activities_model.Notification { t.Helper() diff --git a/services/convert/status.go b/services/convert/status.go index fe8240a8f72..a8ef94d107d 100644 --- a/services/convert/status.go +++ b/services/convert/status.go @@ -9,6 +9,7 @@ import ( git_model "code.gitea.io/gitea/models/git" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/commitstatus" api "code.gitea.io/gitea/modules/structs" ) @@ -55,6 +56,8 @@ func ToCombinedStatus(ctx context.Context, commitID string, statuses []*git_mode if combinedStatus != nil { status.Statuses = ToCommitStatuses(ctx, statuses) status.State = combinedStatus.State + } else { + status.State = commitstatus.CommitStatusPending } return &status } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 8b69c6bcc6f..7ccf0aa6228 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -521,7 +521,7 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors) // swagger:model MergePullRequestOption type MergePullRequestForm struct { // required: true - // enum: merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged + // enum: ["merge","rebase","rebase-merge","squash","fast-forward-only","manually-merged"] Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged)"` MergeTitleField string MergeMessageField string diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index adc6c181755..e01ff1112bf 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1,11 +1,9 @@ { "consumes": [ - "application/json", - "text/plain" + "application/json" ], "produces": [ - "application/json", - "text/html" + "application/json" ], "schemes": [ "https", @@ -86,7 +84,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -135,7 +133,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -205,7 +203,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -2008,7 +2006,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -2073,7 +2071,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -2157,7 +2155,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -4989,7 +4987,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -5068,7 +5066,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -5166,7 +5164,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -5287,7 +5285,7 @@ "required": true }, { - "type": "string", + "type": "integer", "description": "id of the run", "name": "run", "in": "path", @@ -10230,7 +10228,7 @@ } ], "responses": { - "200": { + "204": { "$ref": "#/responses/empty" }, "403": { @@ -11969,7 +11967,7 @@ } ], "responses": { - "200": { + "204": { "$ref": "#/responses/empty" }, "403": { @@ -14495,6 +14493,9 @@ "200": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -18670,7 +18671,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunnersResponse" + "$ref": "#/responses/RunnerList" }, "400": { "$ref": "#/responses/error" @@ -18719,7 +18720,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -18789,7 +18790,7 @@ ], "responses": { "200": { - "$ref": "#/definitions/ActionRunner" + "$ref": "#/responses/Runner" }, "400": { "$ref": "#/responses/error" @@ -23887,7 +23888,16 @@ "x-go-name": "CommitID" }, "event": { - "$ref": "#/definitions/ReviewStateType" + "type": "string", + "enum": [ + "APPROVED", + "PENDING", + "COMMENT", + "REQUEST_CHANGES", + "REQUEST_REVIEW" + ], + "x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user", + "x-go-name": "Event" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -24835,6 +24845,10 @@ "state": { "description": "State indicates the updated state of the milestone", "type": "string", + "enum": [ + "open", + "closed" + ], "x-go-name": "State" }, "title": { @@ -26272,7 +26286,13 @@ "$ref": "#/definitions/RepositoryMeta" }, "state": { - "$ref": "#/definitions/StateType" + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" }, "time_estimate": { "type": "integer", @@ -26373,7 +26393,16 @@ "x-go-name": "ID" }, "type": { - "$ref": "#/definitions/IssueFormFieldType" + "type": "string", + "enum": [ + "markdown", + "textarea", + "input", + "dropdown", + "checkboxes" + ], + "x-go-enum-desc": "markdown IssueFormFieldTypeMarkdown\ntextarea IssueFormFieldTypeTextarea\ninput IssueFormFieldTypeInput\ndropdown IssueFormFieldTypeDropdown\ncheckboxes IssueFormFieldTypeCheckboxes", + "x-go-name": "Type" }, "validations": { "type": "object", @@ -26383,23 +26412,18 @@ "visible": { "type": "array", "items": { - "$ref": "#/definitions/IssueFormFieldVisible" + "type": "string", + "enum": [ + "form", + "content" + ], + "x-go-enum-desc": "form IssueFormFieldVisibleForm\ncontent IssueFormFieldVisibleContent" }, "x-go-name": "Visible" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "IssueFormFieldType": { - "type": "string", - "title": "IssueFormFieldType defines issue form field type, can be \"markdown\", \"textarea\", \"input\", \"dropdown\" or \"checkboxes\"", - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, - "IssueFormFieldVisible": { - "description": "IssueFormFieldVisible defines issue form field visible", - "type": "string", - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "IssueLabelsOption": { "description": "IssueLabelsOption a collection of labels", "type": "object", @@ -26897,7 +26921,14 @@ "x-go-name": "OpenIssues" }, "state": { - "$ref": "#/definitions/StateType" + "description": "State indicates if the milestone is open or closed\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" }, "title": { "description": "Title is the title of the milestone", @@ -27111,7 +27142,15 @@ "x-go-name": "LatestCommentURL" }, "state": { - "$ref": "#/definitions/StateType" + "description": "State indicates the current state of the notification subject\nopen NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request", + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ], + "x-go-enum-desc": "open NotifySubjectStateOpen NotifySubjectStateOpen is an open subject\nclosed NotifySubjectStateClosed NotifySubjectStateClosed is a closed subject\nmerged NotifySubjectStateMerged NotifySubjectStateMerged is a merged pull request", + "x-go-name": "State" }, "title": { "description": "Title is the title of the notification subject", @@ -27119,7 +27158,16 @@ "x-go-name": "Title" }, "type": { - "$ref": "#/definitions/NotifySubjectType" + "description": "Type indicates the type of the notification subject\nIssue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification", + "type": "string", + "enum": [ + "Issue", + "Pull", + "Commit", + "Repository" + ], + "x-go-enum-desc": "Issue NotifySubjectIssue NotifySubjectIssue an issue is subject of an notification\nPull NotifySubjectPull NotifySubjectPull an pull is subject of an notification\nCommit NotifySubjectCommit NotifySubjectCommit an commit is subject of an notification\nRepository NotifySubjectRepository NotifySubjectRepository an repository is subject of an notification", + "x-go-name": "Type" }, "url": { "description": "URL is the API URL for the notification subject", @@ -27169,11 +27217,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "NotifySubjectType": { - "description": "NotifySubjectType represent type of notification subject", - "type": "string", - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "OAuth2Application": { "type": "object", "title": "OAuth2Application represents an OAuth2 application.", @@ -27806,7 +27849,14 @@ "x-go-name": "ReviewComments" }, "state": { - "$ref": "#/definitions/StateType" + "description": "The current state of the pull request\nopen StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "type": "string", + "enum": [ + "open", + "closed" + ], + "x-go-enum-desc": "open StateOpen StateOpen pr is opened\nclosed StateClosed StateClosed pr is closed", + "x-go-name": "State" }, "title": { "description": "The title of the pull request", @@ -27898,7 +27948,16 @@ "x-go-name": "Stale" }, "state": { - "$ref": "#/definitions/ReviewStateType" + "type": "string", + "enum": [ + "APPROVED", + "PENDING", + "COMMENT", + "REQUEST_CHANGES", + "REQUEST_REVIEW" + ], + "x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user", + "x-go-name": "State" }, "submitted_at": { "type": "string", @@ -28635,11 +28694,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "ReviewStateType": { - "description": "ReviewStateType review state type", - "type": "string", - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "RunDetails": { "description": "RunDetails returns workflow_dispatch runid and url", "type": "object", @@ -28714,11 +28768,6 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, - "StateType": { - "description": "StateType issue state type", - "type": "string", - "x-go-package": "code.gitea.io/gitea/modules/structs" - }, "StopWatch": { "description": "StopWatch represent a running stopwatch", "type": "object", @@ -28772,7 +28821,16 @@ "x-go-name": "Body" }, "event": { - "$ref": "#/definitions/ReviewStateType" + "type": "string", + "enum": [ + "APPROVED", + "PENDING", + "COMMENT", + "REQUEST_CHANGES", + "REQUEST_REVIEW" + ], + "x-go-enum-desc": "APPROVED ReviewStateApproved ReviewStateApproved pr is approved\nPENDING ReviewStatePending ReviewStatePending pr state is pending\nCOMMENT ReviewStateComment ReviewStateComment is a comment review\nREQUEST_CHANGES ReviewStateRequestChanges ReviewStateRequestChanges changes for pr are requested\nREQUEST_REVIEW ReviewStateRequestReview ReviewStateRequestReview review is requested from user", + "x-go-name": "Event" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" diff --git a/tests/integration/api_issue_reaction_test.go b/tests/integration/api_issue_reaction_test.go index 01588f9900d..d099e72edbd 100644 --- a/tests/integration/api_issue_reaction_test.go +++ b/tests/integration/api_issue_reaction_test.go @@ -44,7 +44,7 @@ func TestAPIIssuesReactions(t *testing.T) { req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ Reaction: "zzz", }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusOK) + MakeRequest(t, req, http.StatusNoContent) // Add allowed reaction req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ @@ -111,7 +111,7 @@ func TestAPICommentReactions(t *testing.T) { req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ Reaction: "eyes", }).AddTokenAuth(token) - MakeRequest(t, req, http.StatusOK) + MakeRequest(t, req, http.StatusNoContent) t.Run("UnrelatedCommentID", func(t *testing.T) { // Using the ID of a comment that does not belong to the repository must fail From c31e0cfc1c150abc9d1f361c16ca6b5ea0b7b97e Mon Sep 17 00:00:00 2001 From: Myers Carpenter Date: Mon, 30 Mar 2026 09:44:32 -0400 Subject: [PATCH 18/39] 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 1efe3334ca3..a34e4b0693a 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 3ad2f78bd34..ad320e2b829 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 db205380e45..20ccd099a47 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 a045bba49cc..ef86f413b70 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 acd67fece4c..61f11d8f191 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 bb675811f2d..5c7c99f2cef 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 e01ff1112bf..5ae0f197df9 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 8d85543dc8a..c3e96059de3 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 19/39] 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 4f3c4e9ff4c..a1112def0cb 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 20/39] 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 41e4ca7376d..380c3ac4551 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 9c8da951fc1..25bd28a8265 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 41fb1e7e6f6..7d9e8160428 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 9daffb04f1c..39911e25481 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 72d2c27eafd..e3dcf27cc4a 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 2f3d06d7806..f8322381ca1 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 66c9d718ea1..3b0af6ddc31 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}}