fix(web): keep the merge box inside its popover and the MR URL after a refused merge
- commit
- 1b457529c92e378d1102b4114238bdfc2b7b9d4d
- author
- brice · · 2026-10-19 14:30 UTC
- committer
- brice · · 2026-10-19 18:30 UTC
- parent
- b5b4bf9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +783 -759
M internal/adapters/web/mr.go +7 -0
| @@ -267,6 +267,10 @@ type MRPage struct { | |||
| 267 | 267 | // MergeError is why a merge was refused when the page has no merge box | |
| 268 | 268 | // to show it in (the request is not open any more). | |
| 269 | 269 | MergeError string | |
| 270 | + | // Canonical is the address of the request, set when the page answers a | |
| 271 | + | // refused action from the address of that action: the script puts it in | |
| 272 | + | // the address bar, so that a reload does not post again. | |
| 273 | + | Canonical string | |
| 270 | 274 | ||
| 271 | 275 | Since *SinceNav | |
| 272 | 276 | SinceNote string | |
| @@ -716,6 +720,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 716 | 720 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | |
| 717 | 721 | } | |
| 718 | 722 | } | |
| 723 | + | if mergeErrorOf(ctx) != "" { | |
| 724 | + | p.Canonical = self | |
| 725 | + | } | |
| 719 | 726 | if p.Box == nil { | |
| 720 | 727 | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |
| 721 | 728 | } | |
| @@ -267,6 +267,10 @@ type MRPage struct { | |||||
| 267 | // MergeError is why a merge was refused when the page has no merge box | 267 | // MergeError is why a merge was refused when the page has no merge box | ||
| 268 | // to show it in (the request is not open any more). | 268 | // to show it in (the request is not open any more). | ||
| 269 | MergeError string | 269 | MergeError string | ||
| 270 | + | // Canonical is the address of the request, set when the page answers a | |||
| 271 | + | // refused action from the address of that action: the script puts it in | |||
| 272 | + | // the address bar, so that a reload does not post again. | |||
| 273 | + | Canonical string | |||
| 270 | 274 | ||||
| 271 | Since *SinceNav | 275 | Since *SinceNav | ||
| 272 | SinceNote string | 276 | SinceNote string | ||
| @@ -716,6 +720,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 716 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | 720 | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | ||
| 717 | } | 721 | } | ||
| 718 | } | 722 | } | ||
| 723 | + | if mergeErrorOf(ctx) != "" { | |||
| 724 | + | p.Canonical = self | |||
| 725 | + | } | |||
| 719 | if p.Box == nil { | 726 | if p.Box == nil { | ||
| 720 | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | 727 | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | ||
| 721 | } | 728 | } | ||
M internal/adapters/web/mr.templ +6 -3
| @@ -187,6 +187,9 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 187 | 187 | <div | |
| 188 | 188 | id="diffs" | |
| 189 | 189 | data-mr | |
| 190 | + | if p.Canonical != "" { | |
| 191 | + | data-canonical={ p.Canonical } | |
| 192 | + | } | |
| 190 | 193 | if p.Both { | |
| 191 | 194 | data-diff={ p.View } | |
| 192 | 195 | } else { | |
| @@ -518,11 +521,11 @@ templ mergeMenu(p MRPage) { | |||
| 518 | 521 | </fieldset> | |
| 519 | 522 | if p.Box.Signed { | |
| 520 | 523 | <p class="d-note" id="mr-signing"> | |
| 521 | - | squash and merge commits are signed with ermite's own key, generated automatically on first start. | |
| 524 | + | squash and merge commits are signed with ermite's own key (generated on first start). | |
| 522 | 525 | if p.Box.Fingerprint != "" { | |
| 523 | - | { " " }key <code>{ p.Box.Fingerprint }</code>. | |
| 526 | + | fingerprint <code>{ p.Box.Fingerprint }</code> | |
| 524 | 527 | } | |
| 525 | - | { " " }<code>ermite signing-key show</code> prints the public key to verify them. | |
| 528 | + | <code>ermite signing-key show</code> prints it. | |
| 526 | 529 | </p> | |
| 527 | 530 | } | |
| 528 | 531 | if p.Box.CanDeleteBranch { | |
| @@ -187,6 +187,9 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 187 | <div | 187 | <div | ||
| 188 | id="diffs" | 188 | id="diffs" | ||
| 189 | data-mr | 189 | data-mr | ||
| 190 | + | if p.Canonical != "" { | |||
| 191 | + | data-canonical={ p.Canonical } | |||
| 192 | + | } | |||
| 190 | if p.Both { | 193 | if p.Both { | ||
| 191 | data-diff={ p.View } | 194 | data-diff={ p.View } | ||
| 192 | } else { | 195 | } else { | ||
| @@ -518,11 +521,11 @@ templ mergeMenu(p MRPage) { | |||||
| 518 | </fieldset> | 521 | </fieldset> | ||
| 519 | if p.Box.Signed { | 522 | if p.Box.Signed { | ||
| 520 | <p class="d-note" id="mr-signing"> | 523 | <p class="d-note" id="mr-signing"> | ||
| 521 | - | squash and merge commits are signed with ermite's own key, generated automatically on first start. | 524 | + | squash and merge commits are signed with ermite's own key (generated on first start). |
| 522 | if p.Box.Fingerprint != "" { | 525 | if p.Box.Fingerprint != "" { | ||
| 523 | - | { " " }key <code>{ p.Box.Fingerprint }</code>. | 526 | + | fingerprint <code>{ p.Box.Fingerprint }</code> |
| 524 | } | 527 | } | ||
| 525 | - | { " " }<code>ermite signing-key show</code> prints the public key to verify them. | 528 | + | <code>ermite signing-key show</code> prints it. |
| 526 | </p> | 529 | </p> | ||
| 527 | } | 530 | } | ||
| 528 | if p.Box.CanDeleteBranch { | 531 | if p.Box.CanDeleteBranch { | ||
M internal/adapters/web/mr_templ.go +756 -755 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +6 -0
| @@ -65,6 +65,12 @@ | |||
| 65 | 65 | & .menu-pop .btn kbd { margin-left: auto; } | |
| 66 | 66 | } | |
| 67 | 67 | .mmenu { | |
| 68 | + | & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; } | |
| 69 | + | & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; } | |
| 70 | + | & .d-acts { display: grid; gap: var(--s3); } | |
| 71 | + | & .d-modes label, & .d-check { min-width: 0; } | |
| 72 | + | & code { overflow-wrap: anywhere; } | |
| 73 | + | & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; } | |
| 68 | 74 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | |
| 69 | 75 | & .d-modes label, & .d-check { | |
| 70 | 76 | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | |
| @@ -65,6 +65,12 @@ | |||||
| 65 | & .menu-pop .btn kbd { margin-left: auto; } | 65 | & .menu-pop .btn kbd { margin-left: auto; } | ||
| 66 | } | 66 | } | ||
| 67 | .mmenu { | 67 | .mmenu { | ||
| 68 | + | & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; } | |||
| 69 | + | & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; } | |||
| 70 | + | & .d-acts { display: grid; gap: var(--s3); } | |||
| 71 | + | & .d-modes label, & .d-check { min-width: 0; } | |||
| 72 | + | & code { overflow-wrap: anywhere; } | |||
| 73 | + | & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; } | |||
| 68 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | 74 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | ||
| 69 | & .d-modes label, & .d-check { | 75 | & .d-modes label, & .d-check { | ||
| 70 | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | 76 | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | ||
M internal/adapters/web/static/js/mr.js +4 -0
| @@ -285,6 +285,10 @@ window.ermiteMR = (() => { | |||
| 285 | 285 | let folded = false; | |
| 286 | 286 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | |
| 287 | 287 | if (folded) foldFiles(true); | |
| 288 | + | // The page answers a refused action from the address of the action: show | |
| 289 | + | // the address of the request, so that a reload does not post again. | |
| 290 | + | const canonical = $('#diffs[data-canonical]')?.dataset.canonical; | |
| 291 | + | if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash); | |
| 288 | 292 | watch(); | |
| 289 | 293 | // A link to a file or a comment that is not loaded yet. | |
| 290 | 294 | const hash = () => { | |
| @@ -285,6 +285,10 @@ window.ermiteMR = (() => { | |||||
| 285 | let folded = false; | 285 | let folded = false; | ||
| 286 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | 286 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | ||
| 287 | if (folded) foldFiles(true); | 287 | if (folded) foldFiles(true); | ||
| 288 | + | // The page answers a refused action from the address of the action: show | |||
| 289 | + | // the address of the request, so that a reload does not post again. | |||
| 290 | + | const canonical = $('#diffs[data-canonical]')?.dataset.canonical; | |||
| 291 | + | if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash); | |||
| 288 | watch(); | 292 | watch(); | ||
| 289 | // A link to a file or a comment that is not loaded yet. | 293 | // A link to a file or a comment that is not loaded yet. | ||
| 290 | const hash = () => { | 294 | const hash = () => { | ||
M internal/app/web_mr_test.go +4 -1
| @@ -468,6 +468,9 @@ func TestWebMergeRequestDraft(t *testing.T) { | |||
| 468 | 468 | ||
| 469 | 469 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | |
| 470 | 470 | assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged") | |
| 471 | + | assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address") | |
| 472 | + | assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline") | |
| 473 | + | assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker") | |
| 471 | 474 | ||
| 472 | 475 | res = b.form("/proj/merge-requests/1/ready", token, nil) | |
| 473 | 476 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| @@ -777,7 +780,7 @@ func TestWebMergeRequestMergeabilityBadge(t *testing.T) { | |||
| 777 | 780 | assert.Contains(t, body, `name="mode" value="fast-forward" checked`) | |
| 778 | 781 | assert.NotContains(t, body, `value="fast-forward" checked disabled`) | |
| 779 | 782 | assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them") | |
| 780 | - | assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start") | |
| 783 | + | assert.Contains(t, body, "signed with ermite's own key (generated on first start)") | |
| 781 | 784 | assert.Contains(t, body, "<code>ermite signing-key show</code>") | |
| 782 | 785 | assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key") | |
| 783 | 786 | assert.Contains(t, body, `:disabled="`, "the controls follow the badge") | |
| @@ -468,6 +468,9 @@ func TestWebMergeRequestDraft(t *testing.T) { | |||||
| 468 | 468 | ||||
| 469 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | 469 | res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}}) | ||
| 470 | assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged") | 470 | assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged") | ||
| 471 | + | assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address") | |||
| 472 | + | assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline") | |||
| 473 | + | assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker") | |||
| 471 | 474 | ||||
| 472 | res = b.form("/proj/merge-requests/1/ready", token, nil) | 475 | res = b.form("/proj/merge-requests/1/ready", token, nil) | ||
| 473 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 476 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| @@ -777,7 +780,7 @@ func TestWebMergeRequestMergeabilityBadge(t *testing.T) { | |||||
| 777 | assert.Contains(t, body, `name="mode" value="fast-forward" checked`) | 780 | assert.Contains(t, body, `name="mode" value="fast-forward" checked`) | ||
| 778 | assert.NotContains(t, body, `value="fast-forward" checked disabled`) | 781 | assert.NotContains(t, body, `value="fast-forward" checked disabled`) | ||
| 779 | assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them") | 782 | assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them") | ||
| 780 | - | assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start") | 783 | + | assert.Contains(t, body, "signed with ermite's own key (generated on first start)") |
| 781 | assert.Contains(t, body, "<code>ermite signing-key show</code>") | 784 | assert.Contains(t, body, "<code>ermite signing-key show</code>") | ||
| 782 | assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key") | 785 | assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key") | ||
| 783 | assert.Contains(t, body, `:disabled="`, "the controls follow the badge") | 786 | assert.Contains(t, body, `:disabled="`, "the controls follow the badge") | ||