detached 1b45752

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 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
@@ -187,6 +187,9 @@ templ MergeRequest(l Layout, p MRPage) {
187187 <div
188188 id="diffs"
189189 data-mr
190+ if p.Canonical != "" {
191+ data-canonical={ p.Canonical }
192+ }
190193 if p.Both {
191194 data-diff={ p.View }
192195 } 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 {
@@ -187,6 +187,9 @@ templ MergeRequest(l Layout, p MRPage) {
187 <div187 <div
188 id="diffs"188 id="diffs"
189 data-mr189 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 @@
6565 & .menu-pop .btn kbd { margin-left: auto; }
6666 }
6767 .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; }
6874 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
6975 & .d-modes label, & .d-check {
7076 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 = (() => {
285285 let folded = false;
286286 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
287287 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);
288292 watch();
289293 // A link to a file or a comment that is not loaded yet.
290294 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) {
468468
469469 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
470470 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")
471474
472475 res = b.form("/proj/merge-requests/1/ready", token, nil)
473476 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -777,7 +780,7 @@ func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
777780 assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
778781 assert.NotContains(t, body, `value="fast-forward" checked disabled`)
779782 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)")
781784 assert.Contains(t, body, "<code>ermite signing-key show</code>")
782785 assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
783786 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")

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 2

Tags 41

↑↓ moveenter openesc close