detached 72d0556

fix(web): keep the merge box inside its popover and the MR URL after a refused merge

commit
72d05565440393e987aa46ca58611e6b1928e6c0
author
brice · · 2026-10-19 14:30 UTC
committer
brice · · 2026-10-19 14:37 UTC
parent
1790409
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
267267 // MergeError is why a merge was refused when the page has no merge box
268268 // to show it in (the request is not open any more).
269269 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
270274
271275 Since *SinceNav
272276 SinceNote string
@@ -716,6 +720,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
716720 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
717721 }
718722 }
723+ if mergeErrorOf(ctx) != "" {
724+ p.Canonical = self
725+ }
719726 if p.Box == nil {
720727 p.MergeError = mergeErrorOf(ctx) // the request is not open any more
721728 }
@@ -267,6 +267,10 @@ type MRPage struct {
267 // MergeError is why a merge was refused when the page has no merge box267 // 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 string269 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 *SinceNav275 Since *SinceNav
272 SinceNote string276 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 more727 p.MergeError = mergeErrorOf(ctx) // the request is not open any more
721 }728 }
M internal/adapters/web/mr.templ +6 -3
@@ -185,6 +185,9 @@ templ MergeRequest(l Layout, p MRPage) {
185185 <div
186186 id="diffs"
187187 data-mr
188+ if p.Canonical != "" {
189+ data-canonical={ p.Canonical }
190+ }
188191 if p.Both {
189192 data-diff={ p.View }
190193 } else {
@@ -518,11 +521,11 @@ templ mergeMenu(p MRPage) {
518521 </fieldset>
519522 if p.Box.Signed {
520523 <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).
522525 if p.Box.Fingerprint != "" {
523- { " " }key <code>{ p.Box.Fingerprint }</code>.
526+ fingerprint <code>{ p.Box.Fingerprint }</code>
524527 }
525- { " " }<code>ermite signing-key show</code> prints the public key to verify them.
528+ <code>ermite signing-key show</code> prints it.
526529 </p>
527530 }
528531 if p.Box.CanDeleteBranch {
@@ -185,6 +185,9 @@ templ MergeRequest(l Layout, p MRPage) {
185 <div185 <div
186 id="diffs"186 id="diffs"
187 data-mr187 data-mr
188+ if p.Canonical != "" {
189+ data-canonical={ p.Canonical }
190+ }
188 if p.Both {191 if p.Both {
189 data-diff={ p.View }192 data-diff={ p.View }
190 } else {193 } 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
@@ -50,6 +50,12 @@
5050 & .kv { margin: 0; }
5151 }
5252 .mmenu {
53+ & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; }
54+ & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; }
55+ & .d-acts { display: grid; gap: var(--s3); }
56+ & .d-modes label, & .d-check { min-width: 0; }
57+ & code { overflow-wrap: anywhere; }
58+ & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; }
5359 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
5460 & .d-modes label, & .d-check {
5561 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
@@ -50,6 +50,12 @@
50 & .kv { margin: 0; }50 & .kv { margin: 0; }
51 }51 }
52 .mmenu {52 .mmenu {
53+ & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; }
54+ & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; }
55+ & .d-acts { display: grid; gap: var(--s3); }
56+ & .d-modes label, & .d-check { min-width: 0; }
57+ & code { overflow-wrap: anywhere; }
58+ & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; }
53 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }59 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
54 & .d-modes label, & .d-check {60 & .d-modes label, & .d-check {
55 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));61 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
@@ -291,6 +291,10 @@ window.ermiteMR = (() => {
291291 let folded = false;
292292 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
293293 if (folded) foldFiles(true);
294+ // The page answers a refused action from the address of the action: show
295+ // the address of the request, so that a reload does not post again.
296+ const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
297+ if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
294298 watch();
295299 // A link to a file or a comment that is not loaded yet.
296300 const hash = () => {
@@ -291,6 +291,10 @@ window.ermiteMR = (() => {
291 let folded = false;291 let folded = false;
292 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }292 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
293 if (folded) foldFiles(true);293 if (folded) foldFiles(true);
294+ // The page answers a refused action from the address of the action: show
295+ // the address of the request, so that a reload does not post again.
296+ const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
297+ if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
294 watch();298 watch();
295 // A link to a file or a comment that is not loaded yet.299 // A link to a file or a comment that is not loaded yet.
296 const hash = () => {300 const hash = () => {
M internal/app/web_mr_test.go +4 -1
@@ -466,6 +466,9 @@ func TestWebMergeRequestDraft(t *testing.T) {
466466
467467 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
468468 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
469+ assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
470+ assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
471+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
469472
470473 res = b.form("/proj/merge-requests/1/ready", token, nil)
471474 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -775,7 +778,7 @@ func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
775778 assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
776779 assert.NotContains(t, body, `value="fast-forward" checked disabled`)
777780 assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
778- assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start")
781+ assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
779782 assert.Contains(t, body, "<code>ermite signing-key show</code>")
780783 assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
781784 assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
@@ -466,6 +466,9 @@ func TestWebMergeRequestDraft(t *testing.T) {
466 466
467 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})467 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
468 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")468 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
469+ assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
470+ assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
471+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
469 472
470 res = b.form("/proj/merge-requests/1/ready", token, nil)473 res = b.form("/proj/merge-requests/1/ready", token, nil)
471 require.Equal(t, http.StatusSeeOther, res.status, res.body)474 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -775,7 +778,7 @@ func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
775 assert.Contains(t, body, `name="mode" value="fast-forward" checked`)778 assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
776 assert.NotContains(t, body, `value="fast-forward" checked disabled`)779 assert.NotContains(t, body, `value="fast-forward" checked disabled`)
777 assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")780 assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
778- assert.Contains(t, body, "signed with ermite's own key, generated automatically on first start")781+ assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
779 assert.Contains(t, body, "<code>ermite signing-key show</code>")782 assert.Contains(t, body, "<code>ermite signing-key show</code>")
780 assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")783 assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
781 assert.Contains(t, body, `:disabled="`, "the controls follow the badge")784 assert.Contains(t, body, `:disabled="`, "the controls follow the badge")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 45

↑↓ moveenter openesc close