detached 42e48d0

feat(web): move MR commits and checks into a tabbed card footer

Commits and checks leave the rail for a collapsed footer of the main card,
with ARIA tabs and live checks. Merge settings and the signing note move to
the More menu, and the merge menu drops the head and target rows.
commit
42e48d01ef1c2af5adec8f291cf56a83fde8bc3e
author
brice · · 2026-10-20 05:08 UTC
parent
cc7ba64
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +884 -655
M docs/merge-requests.md +14 -0
@@ -104,3 +104,17 @@ report whether more exist; ancestry, rather than the bounded list, detects a
104104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`
105105 (100) commits. These history queries remain on the merge-request service
106106 because it owns the merge-git port.
107+
108+### Review page panels
109+
110+The main request card ends with a collapsed Commits / Checks footer. With
111+scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening
112+the footer shows both sections. The checks summary above the diff opens the
113+Checks tab and continues to update live. Commit totals use `N+` when the branch
114+list is capped: the reader's existing count operation counts all reachable
115+ancestors, not the source-minus-target range.
116+
117+The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
118+are read-only information in the More (⋯) menu, available to every viewer.
119+Signing-key information is also there when a merge box offers signed modes;
120+merge actions remain restricted to their existing permissions.
@@ -104,3 +104,17 @@ report whether more exist; ancestry, rather than the bounded list, detects a
104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`
105 (100) commits. These history queries remain on the merge-request service105 (100) commits. These history queries remain on the merge-request service
106 because it owns the merge-git port.106 because it owns the merge-git port.
107+
108+### Review page panels
109+
110+The main request card ends with a collapsed Commits / Checks footer. With
111+scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening
112+the footer shows both sections. The checks summary above the diff opens the
113+Checks tab and continues to update live. Commit totals use `N+` when the branch
114+list is capped: the reader's existing count operation counts all reachable
115+ancestors, not the source-minus-target range.
116+
117+The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
118+are read-only information in the More (⋯) menu, available to every viewer.
119+Signing-key information is also there when a merge box offers signed modes;
120+merge actions remain restricted to their existing permissions.
M internal/adapters/web/actions.templ +2 -2
@@ -434,7 +434,7 @@ templ CommitChecks(c ChecksSummary) {
434434 }
435435
436436 // MRChecksSum is the state of the checks of a merge request in the bar above
437-// the diff, a link to the checks of the rail. The event stream replaces it
437+// the diff, a link to the checks of the card footer. The event stream replaces it
438438 // with the checks section (same event, fragments.go).
439439 templ MRChecksSum(c ChecksSummary) {
440440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
@@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) {
447447 </a>
448448 }
449449
450-// MRChecks is the checks section of the rail of a merge request.
450+// MRChecks is the checks section of the footer of a merge request.
451451 templ MRChecks(c ChecksSection) {
452452 <section aria-labelledby="r-checks" id="mr-checks">
453453 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
@@ -434,7 +434,7 @@ templ CommitChecks(c ChecksSummary) {
434 }434 }
435 435
436 // MRChecksSum is the state of the checks of a merge request in the bar above436 // MRChecksSum is the state of the checks of a merge request in the bar above
437-// the diff, a link to the checks of the rail. The event stream replaces it437+// the diff, a link to the checks of the card footer. The event stream replaces it
438 // with the checks section (same event, fragments.go).438 // with the checks section (same event, fragments.go).
439 templ MRChecksSum(c ChecksSummary) {439 templ MRChecksSum(c ChecksSummary) {
440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
@@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) {
447 </a>447 </a>
448 }448 }
449 449
450-// MRChecks is the checks section of the rail of a merge request.450+// MRChecks is the checks section of the footer of a merge request.
451 templ MRChecks(c ChecksSection) {451 templ MRChecks(c ChecksSection) {
452 <section aria-labelledby="r-checks" id="mr-checks">452 <section aria-labelledby="r-checks" id="mr-checks">
453 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>453 <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
M internal/adapters/web/actions_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/checks.go +1 -1
@@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
8181 return sum
8282 }
8383
84-// checksSection is the checks of the rail of a merge request, with the rule of
84+// checksSection is the checks of the footer of a merge request, with the rule of
8585 // the repository that applies to them.
8686 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
8787 sec := ChecksSection{}
@@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
81 return sum81 return sum
82 }82 }
83 83
84-// checksSection is the checks of the rail of a merge request, with the rule of84+// checksSection is the checks of the footer of a merge request, with the rule of
85 // the repository that applies to them.85 // the repository that applies to them.
86 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {86 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
87 sec := ChecksSection{}87 sec := ChecksSection{}
M internal/adapters/web/jstest/mr.test.mjs +43 -0
@@ -92,3 +92,46 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
9292 globalThis.requestAnimationFrame=savedRAF;
9393 }
9494 });
95+
96+test('footer tabs switch panels, support arrows, and open on checks hash', () => {
97+ const oldDocument = globalThis.document;
98+ const oldLocation = globalThis.location;
99+ const oldAdd = globalThis.addEventListener;
100+ const listeners = {};
101+ const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
102+ setAttribute(k,v) { this.attrs[k]=v; },
103+ addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } });
104+ const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
105+ const panels = [node('mr-commits'), node('mr-checks-panel')];
106+ const footer = node('mr-panels'); footer.open=false;
107+ const nav = node('tabs'); nav.querySelectorAll=()=>tabs;
108+ footer.querySelectorAll=()=>panels;
109+ globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null,
110+ addEventListener(k,v) {listeners[k]=v;} };
111+ globalThis.location = {hash:''};
112+ globalThis.addEventListener = (k,v) => {listeners[k]=v;};
113+ try {
114+ window.ermiteMR.panels();
115+ assert.equal(footer.open,false);
116+ assert.equal(panels[0].hidden,false);
117+ assert.equal(panels[1].hidden,true);
118+ tabs[1].events.click({preventDefault(){}});
119+ assert.equal(panels[0].hidden,true);
120+ assert.equal(tabs[1].attrs['aria-selected'],'true');
121+ tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}});
122+ assert.equal(tabs[0].focused,true);
123+ assert.equal(panels[0].hidden,false);
124+ location.hash='#mr-checks'; listeners.hashchange();
125+ assert.equal(footer.open,true);
126+ assert.equal(panels[1].hidden,false);
127+ footer.open=false;
128+ listeners.click({target:{closest:()=>true}});
129+ assert.equal(footer.open,true);
130+ assert.equal(C.tabIndex('Home',1,2),0);
131+ assert.equal(C.tabIndex('End',0,2),1);
132+ assert.equal(C.tabIndex('ArrowLeft',0,2),1);
133+ assert.equal(C.tabIndex('Escape',0,2),null);
134+ } finally {
135+ globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;
136+ }
137+});
@@ -92,3 +92,46 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
92 globalThis.requestAnimationFrame=savedRAF;92 globalThis.requestAnimationFrame=savedRAF;
93 }93 }
94 });94 });
95+
96+test('footer tabs switch panels, support arrows, and open on checks hash', () => {
97+ const oldDocument = globalThis.document;
98+ const oldLocation = globalThis.location;
99+ const oldAdd = globalThis.addEventListener;
100+ const listeners = {};
101+ const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
102+ setAttribute(k,v) { this.attrs[k]=v; },
103+ addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } });
104+ const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
105+ const panels = [node('mr-commits'), node('mr-checks-panel')];
106+ const footer = node('mr-panels'); footer.open=false;
107+ const nav = node('tabs'); nav.querySelectorAll=()=>tabs;
108+ footer.querySelectorAll=()=>panels;
109+ globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null,
110+ addEventListener(k,v) {listeners[k]=v;} };
111+ globalThis.location = {hash:''};
112+ globalThis.addEventListener = (k,v) => {listeners[k]=v;};
113+ try {
114+ window.ermiteMR.panels();
115+ assert.equal(footer.open,false);
116+ assert.equal(panels[0].hidden,false);
117+ assert.equal(panels[1].hidden,true);
118+ tabs[1].events.click({preventDefault(){}});
119+ assert.equal(panels[0].hidden,true);
120+ assert.equal(tabs[1].attrs['aria-selected'],'true');
121+ tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}});
122+ assert.equal(tabs[0].focused,true);
123+ assert.equal(panels[0].hidden,false);
124+ location.hash='#mr-checks'; listeners.hashchange();
125+ assert.equal(footer.open,true);
126+ assert.equal(panels[1].hidden,false);
127+ footer.open=false;
128+ listeners.click({target:{closest:()=>true}});
129+ assert.equal(footer.open,true);
130+ assert.equal(C.tabIndex('Home',1,2),0);
131+ assert.equal(C.tabIndex('End',0,2),1);
132+ assert.equal(C.tabIndex('ArrowLeft',0,2),1);
133+ assert.equal(C.tabIndex('Escape',0,2),null);
134+ } finally {
135+ globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;
136+ }
137+});
M internal/adapters/web/mr.go +3 -0
@@ -291,6 +291,7 @@ type MRPage struct {
291291
292292 Commits []MRCommit
293293 CommitsNote string
294+ CommitCount string
294295 Modes []string
295296 ChecksBlock bool
296297 // Checks are the CI checks of the head of the request, ChecksSum their
@@ -734,7 +735,9 @@ func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.M
734735 })
735736 }
736737 lastRead := res.LastReadID.String()
738+ p.CommitCount = itoa(len(p.Commits))
737739 if res.CommitsTruncated {
740+ p.CommitCount += "+"
738741 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
739742 }
740743
@@ -291,6 +291,7 @@ type MRPage struct {
291 291
292 Commits []MRCommit292 Commits []MRCommit
293 CommitsNote string293 CommitsNote string
294+ CommitCount string
294 Modes []string295 Modes []string
295 ChecksBlock bool296 ChecksBlock bool
296 // Checks are the CI checks of the head of the request, ChecksSum their297 // Checks are the CI checks of the head of the request, ChecksSum their
@@ -734,7 +735,9 @@ func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.M
734 })735 })
735 }736 }
736 lastRead := res.LastReadID.String()737 lastRead := res.LastReadID.String()
738+ p.CommitCount = itoa(len(p.Commits))
737 if res.CommitsTruncated {739 if res.CommitsTruncated {
740+ p.CommitCount += "+"
738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."741 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
739 }742 }
740 743
M internal/adapters/web/mr.templ +105 -82
@@ -147,6 +147,7 @@ templ MergeRequest(l Layout, p MRPage) {
147147 @templ.Raw(string(p.Desc))
148148 </article>
149149 }
150+ @mrFooter(p)
150151 </section>
151152 if p.Deleting != "" {
152153 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
@@ -366,6 +367,65 @@ templ commentForm(f CommentForm, suffix string) {
366367 </form>
367368 }
368369
370+templ mrFooter(p MRPage) {
371+ <footer class="mr-footer">
372+ <details id="mr-panels">
373+ <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary>
374+ <nav data-mr-tabs aria-label="Branch details">
375+ <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
376+ <a id="mr-tab-checks" href="#mr-checks">
377+ { "Checks " }
378+ <span data-checks-label>
379+ if p.ChecksSum.Total > 0 {
380+ { p.ChecksSum.Title }
381+ } else {
382+ no checks
383+ }
384+ </span>
385+ </a>
386+ </nav>
387+ <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits">
388+ <table class="mr-commits">
389+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
390+ <tbody>
391+ for _, c := range p.Commits {
392+ <tr data-new?={ c.New }>
393+ <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
394+ <td>{ c.Subject }</td>
395+ <td>{ c.Author }</td>
396+ <td>
397+ if c.WhenAt != "" {
398+ <time datetime={ c.WhenAt }>{ c.When }</time>
399+ }
400+ </td>
401+ <td>
402+ if c.Sig != nil {
403+ @signaturePill(c.Sig)
404+ }
405+ </td>
406+ <td>
407+ if c.New {
408+ <span>new</span>
409+ }
410+ </td>
411+ </tr>
412+ }
413+ </tbody>
414+ </table>
415+ if len(p.Commits) == 0 {
416+ <p class="dim">no commit to merge.</p>
417+ }
418+ if p.CommitsNote != "" {
419+ <p class="note">{ p.CommitsNote }</p>
420+ }
421+ </div>
422+ <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks">
423+ @MRChecks(p.Checks)
424+ </div>
425+ </details>
426+ </footer>
427+}
428+
369429 templ mrRail(p MRPage) {
370430 <section aria-labelledby="r-files" class="r-files">
371431 <h2 id="r-files">
@@ -391,51 +451,6 @@ templ mrRail(p MRPage) {
391451 </ul>
392452 </section>
393453 }
394- @MRChecks(p.Checks)
395- <section aria-labelledby="r-commits">
396- <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
397- <ul class="pcommits">
398- for _, c := range p.Commits {
399- <li data-new?={ c.New }>
400- <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
401- <span class="s">{ c.Subject }</span>
402- if c.Sig != nil {
403- @signaturePill(c.Sig)
404- }
405- <span class="mute">
406- { c.Author }
407- if c.WhenAt != "" {
408- { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
409- }
410- if c.New {
411- { " · new" }
412- }
413- </span>
414- </li>
415- }
416- </ul>
417- if len(p.Commits) == 0 {
418- <p class="dim">no commit to merge.</p>
419- }
420- if p.CommitsNote != "" {
421- <p class="note">{ p.CommitsNote }</p>
422- }
423- </section>
424- <section aria-labelledby="r-settings">
425- <h2 id="r-settings">Settings</h2>
426- <dl class="kv">
427- <dt>modes</dt>
428- <dd>{ strings.Join(p.Modes, ", ") }</dd>
429- <dt>checks</dt>
430- <dd>
431- if p.ChecksBlock {
432- failing ones block the merge
433- } else {
434- do not block the merge
435- }
436- </dd>
437- </dl>
438- </section>
439454 }
440455
441456 // mrBar is the bar above the diff, which follows the review: the totals, the
@@ -450,8 +465,8 @@ templ mrBar(p MRPage) {
450465 @MRChecksSum(p.ChecksSum)
451466 if p.Box != nil {
452467 @mergeMenu(p)
453- @moreMenu(p)
454468 }
469+ @moreMenu(p)
455470 </div>
456471 }
457472
@@ -474,18 +489,9 @@ templ mergeMenu(p MRPage) {
474489 <kbd aria-hidden="true">M</kbd>
475490 </summary>
476491 <div class="menu-pop" role="group" aria-label="Merge">
477- <dl class="kv">
478- <dt>head</dt>
479- <dd>
480- if p.Box.HeadGone {
481- gone: the source branch was deleted
482- } else {
483- { p.HeadShort }
484- }
485- </dd>
486- <dt>into</dt>
487- <dd>{ p.Target }</dd>
488- </dl>
492+ if p.Box.HeadGone {
493+ <p class="dim">the source branch was deleted</p>
494+ }
489495 if p.Box.Error != "" {
490496 <p class="d-err" role="alert">{ p.Box.Error }</p>
491497 }
@@ -519,15 +525,6 @@ templ mergeMenu(p MRPage) {
519525 </label>
520526 }
521527 </fieldset>
522- if p.Box.Signed {
523- <p class="d-note" id="mr-signing">
524- squash and merge commits are signed with ermite's own key (generated on first start).
525- if p.Box.Fingerprint != "" {
526- fingerprint <code>{ p.Box.Fingerprint }</code>
527- }
528- <code>ermite signing-key show</code> prints it.
529- </p>
530- }
531528 if p.Box.CanDeleteBranch {
532529 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
533530 }
@@ -589,26 +586,52 @@ templ MRMergeabilityFragment(v MergeabilityView) {
589586 @mrMergeabilitySlot(v)
590587 }
591588
592-// moreMenu is what else the owner does with an open request: mark the head
593-// read, close it, merge it from a terminal.
589+// moreMenu shows read-only settings to every viewer, and keeps owner actions
590+// behind the same merge-box permission as before.
594591 templ moreMenu(p MRPage) {
595592 <details class="menu omenu">
596593 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597594 <div class="menu-pop" role="group" aria-label="More actions">
598- if p.Box.CanMarkRead {
599- <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
600- <input type="hidden" name="head_id" value={ p.Box.Head }/>
601- <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
595+ <section class="merge-settings">
596+ <h2>Merge settings</h2>
597+ <dl class="kv">
598+ <dt>modes</dt>
599+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
600+ <dt>checks</dt>
601+ <dd>
602+ if p.ChecksBlock {
603+ failing ones block the merge
604+ } else {
605+ do not block the merge
606+ }
607+ </dd>
608+ </dl>
609+ </section>
610+ if p.Box != nil {
611+ if p.Box.Signed {
612+ <p class="d-note" id="mr-signing">
613+ squash and merge commits are signed with ermite's own key (generated on first start).
614+ if p.Box.Fingerprint != "" {
615+ fingerprint <code>{ p.Box.Fingerprint }</code>
616+ }
617+ <code>ermite signing-key show</code> prints it.
618+ </p>
619+ }
620+ if p.Box.CanMarkRead {
621+ <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
622+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
623+ <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
624+ </form>
625+ }
626+ <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
627+ <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
602628 </form>
603- }
604- <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
605- <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
606- </form>
607- if !p.Box.HeadGone && p.Box.ReadyAction == "" {
608- <details class="d-or">
609- <summary class="mute">or from a terminal</summary>
610- @CloneBlock(p.Box.CLI)
611- </details>
629+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
630+ <details class="d-or">
631+ <summary class="mute">or from a terminal</summary>
632+ @CloneBlock(p.Box.CLI)
633+ </details>
634+ }
612635 }
613636 </div>
614637 </details>
@@ -147,6 +147,7 @@ templ MergeRequest(l Layout, p MRPage) {
147 @templ.Raw(string(p.Desc))147 @templ.Raw(string(p.Desc))
148 </article>148 </article>
149 }149 }
150+ @mrFooter(p)
150 </section>151 </section>
151 if p.Deleting != "" {152 if p.Deleting != "" {
152 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>153 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
@@ -366,6 +367,65 @@ templ commentForm(f CommentForm, suffix string) {
366 </form>367 </form>
367 }368 }
368 369
370+templ mrFooter(p MRPage) {
371+ <footer class="mr-footer">
372+ <details id="mr-panels">
373+ <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary>
374+ <nav data-mr-tabs aria-label="Branch details">
375+ <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
376+ <a id="mr-tab-checks" href="#mr-checks">
377+ { "Checks " }
378+ <span data-checks-label>
379+ if p.ChecksSum.Total > 0 {
380+ { p.ChecksSum.Title }
381+ } else {
382+ no checks
383+ }
384+ </span>
385+ </a>
386+ </nav>
387+ <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits">
388+ <table class="mr-commits">
389+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
390+ <tbody>
391+ for _, c := range p.Commits {
392+ <tr data-new?={ c.New }>
393+ <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
394+ <td>{ c.Subject }</td>
395+ <td>{ c.Author }</td>
396+ <td>
397+ if c.WhenAt != "" {
398+ <time datetime={ c.WhenAt }>{ c.When }</time>
399+ }
400+ </td>
401+ <td>
402+ if c.Sig != nil {
403+ @signaturePill(c.Sig)
404+ }
405+ </td>
406+ <td>
407+ if c.New {
408+ <span>new</span>
409+ }
410+ </td>
411+ </tr>
412+ }
413+ </tbody>
414+ </table>
415+ if len(p.Commits) == 0 {
416+ <p class="dim">no commit to merge.</p>
417+ }
418+ if p.CommitsNote != "" {
419+ <p class="note">{ p.CommitsNote }</p>
420+ }
421+ </div>
422+ <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks">
423+ @MRChecks(p.Checks)
424+ </div>
425+ </details>
426+ </footer>
427+}
428+
369 templ mrRail(p MRPage) {429 templ mrRail(p MRPage) {
370 <section aria-labelledby="r-files" class="r-files">430 <section aria-labelledby="r-files" class="r-files">
371 <h2 id="r-files">431 <h2 id="r-files">
@@ -391,51 +451,6 @@ templ mrRail(p MRPage) {
391 </ul>451 </ul>
392 </section>452 </section>
393 }453 }
394- @MRChecks(p.Checks)
395- <section aria-labelledby="r-commits">
396- <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
397- <ul class="pcommits">
398- for _, c := range p.Commits {
399- <li data-new?={ c.New }>
400- <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
401- <span class="s">{ c.Subject }</span>
402- if c.Sig != nil {
403- @signaturePill(c.Sig)
404- }
405- <span class="mute">
406- { c.Author }
407- if c.WhenAt != "" {
408- { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
409- }
410- if c.New {
411- { " · new" }
412- }
413- </span>
414- </li>
415- }
416- </ul>
417- if len(p.Commits) == 0 {
418- <p class="dim">no commit to merge.</p>
419- }
420- if p.CommitsNote != "" {
421- <p class="note">{ p.CommitsNote }</p>
422- }
423- </section>
424- <section aria-labelledby="r-settings">
425- <h2 id="r-settings">Settings</h2>
426- <dl class="kv">
427- <dt>modes</dt>
428- <dd>{ strings.Join(p.Modes, ", ") }</dd>
429- <dt>checks</dt>
430- <dd>
431- if p.ChecksBlock {
432- failing ones block the merge
433- } else {
434- do not block the merge
435- }
436- </dd>
437- </dl>
438- </section>
439 }454 }
440 455
441 // mrBar is the bar above the diff, which follows the review: the totals, the456 // mrBar is the bar above the diff, which follows the review: the totals, the
@@ -450,8 +465,8 @@ templ mrBar(p MRPage) {
450 @MRChecksSum(p.ChecksSum)465 @MRChecksSum(p.ChecksSum)
451 if p.Box != nil {466 if p.Box != nil {
452 @mergeMenu(p)467 @mergeMenu(p)
453- @moreMenu(p)
454 }468 }
469+ @moreMenu(p)
455 </div>470 </div>
456 }471 }
457 472
@@ -474,18 +489,9 @@ templ mergeMenu(p MRPage) {
474 <kbd aria-hidden="true">M</kbd>489 <kbd aria-hidden="true">M</kbd>
475 </summary>490 </summary>
476 <div class="menu-pop" role="group" aria-label="Merge">491 <div class="menu-pop" role="group" aria-label="Merge">
477- <dl class="kv">492+ if p.Box.HeadGone {
478- <dt>head</dt>493+ <p class="dim">the source branch was deleted</p>
479- <dd>494+ }
480- if p.Box.HeadGone {
481- gone: the source branch was deleted
482- } else {
483- { p.HeadShort }
484- }
485- </dd>
486- <dt>into</dt>
487- <dd>{ p.Target }</dd>
488- </dl>
489 if p.Box.Error != "" {495 if p.Box.Error != "" {
490 <p class="d-err" role="alert">{ p.Box.Error }</p>496 <p class="d-err" role="alert">{ p.Box.Error }</p>
491 }497 }
@@ -519,15 +525,6 @@ templ mergeMenu(p MRPage) {
519 </label>525 </label>
520 }526 }
521 </fieldset>527 </fieldset>
522- if p.Box.Signed {
523- <p class="d-note" id="mr-signing">
524- squash and merge commits are signed with ermite's own key (generated on first start).
525- if p.Box.Fingerprint != "" {
526- fingerprint <code>{ p.Box.Fingerprint }</code>
527- }
528- <code>ermite signing-key show</code> prints it.
529- </p>
530- }
531 if p.Box.CanDeleteBranch {528 if p.Box.CanDeleteBranch {
532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>529 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
533 }530 }
@@ -589,26 +586,52 @@ templ MRMergeabilityFragment(v MergeabilityView) {
589 @mrMergeabilitySlot(v)586 @mrMergeabilitySlot(v)
590 }587 }
591 588
592-// moreMenu is what else the owner does with an open request: mark the head589+// moreMenu shows read-only settings to every viewer, and keeps owner actions
593-// read, close it, merge it from a terminal.590+// behind the same merge-box permission as before.
594 templ moreMenu(p MRPage) {591 templ moreMenu(p MRPage) {
595 <details class="menu omenu">592 <details class="menu omenu">
596 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>593 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597 <div class="menu-pop" role="group" aria-label="More actions">594 <div class="menu-pop" role="group" aria-label="More actions">
598- if p.Box.CanMarkRead {595+ <section class="merge-settings">
599- <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>596+ <h2>Merge settings</h2>
600- <input type="hidden" name="head_id" value={ p.Box.Head }/>597+ <dl class="kv">
601- <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>598+ <dt>modes</dt>
599+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
600+ <dt>checks</dt>
601+ <dd>
602+ if p.ChecksBlock {
603+ failing ones block the merge
604+ } else {
605+ do not block the merge
606+ }
607+ </dd>
608+ </dl>
609+ </section>
610+ if p.Box != nil {
611+ if p.Box.Signed {
612+ <p class="d-note" id="mr-signing">
613+ squash and merge commits are signed with ermite's own key (generated on first start).
614+ if p.Box.Fingerprint != "" {
615+ fingerprint <code>{ p.Box.Fingerprint }</code>
616+ }
617+ <code>ermite signing-key show</code> prints it.
618+ </p>
619+ }
620+ if p.Box.CanMarkRead {
621+ <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
622+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
623+ <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button>
624+ </form>
625+ }
626+ <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
627+ <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
602 </form>628 </form>
603- }629+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
604- <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>630+ <details class="d-or">
605- <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>631+ <summary class="mute">or from a terminal</summary>
606- </form>632+ @CloneBlock(p.Box.CLI)
607- if !p.Box.HeadGone && p.Box.ReadyAction == "" {633+ </details>
608- <details class="d-or">634+ }
609- <summary class="mute">or from a terminal</summary>
610- @CloneBlock(p.Box.CLI)
611- </details>
612 }635 }
613 </div>636 </div>
614 </details>637 </details>
M internal/adapters/web/mr_templ.go +607 -563 generated file: collapsed
M internal/adapters/web/mr_test.go +32 -0
@@ -128,3 +128,35 @@ func TestMergeabilityLiveEvents(t *testing.T) {
128128 require.NoError(t, err)
129129 assert.Empty(t, fs)
130130 }
131+
132+func TestMRPanelsAndMenus(t *testing.T) {
133+ p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
134+ ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
135+ footer := renderToString(t, mrFooter(p))
136+ assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`)
137+ assert.Contains(t, footer, `data-mr-tabs`)
138+ assert.Contains(t, footer, `id="mr-commits"`)
139+ assert.Contains(t, footer, `id="mr-checks"`)
140+ assert.Contains(t, footer, `<table class="mr-commits">`)
141+ assert.Contains(t, footer, "500+")
142+ assert.Contains(t, footer, "more commits")
143+ assert.Contains(t, footer, "1/2 failed")
144+ rail := renderToString(t, mrRail(p))
145+ assert.Contains(t, rail, "g f")
146+ for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
147+ assert.NotContains(t, rail, text)
148+ }
149+ more := renderToString(t, moreMenu(p))
150+ for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
151+ assert.Contains(t, more, text)
152+ }
153+ merge := renderToString(t, mergeMenu(p))
154+ assert.NotContains(t, merge, `<dl`)
155+ assert.NotContains(t, merge, `mr-signing`)
156+ assert.Contains(t, merge, "the source branch was deleted")
157+ bar := renderToString(t, mrBar(MRPage{}))
158+ assert.Contains(t, bar, `id="mr-ck"`)
159+ assert.Contains(t, bar, `href="#mr-checks"`)
160+ assert.Contains(t, bar, "Merge settings")
161+ assert.NotContains(t, bar, ">Close</button>")
162+}
@@ -128,3 +128,35 @@ func TestMergeabilityLiveEvents(t *testing.T) {
128 require.NoError(t, err)128 require.NoError(t, err)
129 assert.Empty(t, fs)129 assert.Empty(t, fs)
130 }130 }
131+
132+func TestMRPanelsAndMenus(t *testing.T) {
133+ p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
134+ ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
135+ footer := renderToString(t, mrFooter(p))
136+ assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`)
137+ assert.Contains(t, footer, `data-mr-tabs`)
138+ assert.Contains(t, footer, `id="mr-commits"`)
139+ assert.Contains(t, footer, `id="mr-checks"`)
140+ assert.Contains(t, footer, `<table class="mr-commits">`)
141+ assert.Contains(t, footer, "500+")
142+ assert.Contains(t, footer, "more commits")
143+ assert.Contains(t, footer, "1/2 failed")
144+ rail := renderToString(t, mrRail(p))
145+ assert.Contains(t, rail, "g f")
146+ for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
147+ assert.NotContains(t, rail, text)
148+ }
149+ more := renderToString(t, moreMenu(p))
150+ for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
151+ assert.Contains(t, more, text)
152+ }
153+ merge := renderToString(t, mergeMenu(p))
154+ assert.NotContains(t, merge, `<dl`)
155+ assert.NotContains(t, merge, `mr-signing`)
156+ assert.Contains(t, merge, "the source branch was deleted")
157+ bar := renderToString(t, mrBar(MRPage{}))
158+ assert.Contains(t, bar, `id="mr-ck"`)
159+ assert.Contains(t, bar, `href="#mr-checks"`)
160+ assert.Contains(t, bar, "Merge settings")
161+ assert.NotContains(t, bar, ">Close</button>")
162+}
M internal/adapters/web/signature_test.go +1 -1
@@ -145,7 +145,7 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
145145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
146146 t.Parallel()
147147 signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)
148- got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{
148+ got := renderToString(t, mrFooter(MRPage{Commits: []MRCommit{
149149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
150150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
151151 }}))
@@ -145,7 +145,7 @@ func TestCommitPageShowsTheSignature(t *testing.T) {
145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
146 t.Parallel()146 t.Parallel()
147 signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)147 signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false)
148- got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{148+ got := renderToString(t, mrFooter(MRPage{Commits: []MRCommit{
149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
151 }}))151 }}))
M internal/adapters/web/static/css/60-mr.css +19 -2
@@ -8,6 +8,23 @@
88 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
99 }
1010
11+ .mr-footer {
12+ border-top: 1px solid var(--border); padding: var(--s3) var(--s5);
13+ font-size: var(--fs-s);
14+ & summary { cursor: pointer; color: var(--ink-dim); }
15+ & nav { display: flex; gap: var(--s3); padding-block: var(--s3); }
16+ & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); }
17+ & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); }
18+ & [data-mr-panel] { overflow-x: auto; }
19+ & [hidden] { display: none; }
20+ & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
21+ & table { width: 100%; border-collapse: collapse; }
22+ & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
23+ & th { color: var(--ink-dim); font-weight: 500; }
24+ }
25+ .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }
26+ .merge-settings h2 { font-size: var(--fs-s); }
27+
1128 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
1229 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
1330 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
@@ -112,11 +129,11 @@
112129 & .d-or > summary:hover { color: var(--ink-hi); }
113130 & .d-or[open] > .clone { margin-top: var(--s2); }
114131 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
132+ & #mr-signing code { overflow-wrap: anywhere; }
115133 }
116134
117135 /* the rail of a merge request follows the review: it sticks under the
118- header, one screen high, and scrolls on its own to its checks, commits
119- and settings. The list of files keeps its own scroll, under a height, so
136+ header, one screen high, and scrolls on its own to its verdicts. The list of files keeps its own scroll, under a height, so
120137 that the blocks below it stay in reach. Only on a two-column layout. */
121138 @media (min-width: 1081px) {
122139 .rail:has(.r-files) {
@@ -8,6 +8,23 @@
8 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }8 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
9 }9 }
10 10
11+ .mr-footer {
12+ border-top: 1px solid var(--border); padding: var(--s3) var(--s5);
13+ font-size: var(--fs-s);
14+ & summary { cursor: pointer; color: var(--ink-dim); }
15+ & nav { display: flex; gap: var(--s3); padding-block: var(--s3); }
16+ & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); }
17+ & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); }
18+ & [data-mr-panel] { overflow-x: auto; }
19+ & [hidden] { display: none; }
20+ & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
21+ & table { width: 100%; border-collapse: collapse; }
22+ & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
23+ & th { color: var(--ink-dim); font-weight: 500; }
24+ }
25+ .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }
26+ .merge-settings h2 { font-size: var(--fs-s); }
27+
11 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }28 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
12 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }29 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
13 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }30 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
@@ -112,11 +129,11 @@
112 & .d-or > summary:hover { color: var(--ink-hi); }129 & .d-or > summary:hover { color: var(--ink-hi); }
113 & .d-or[open] > .clone { margin-top: var(--s2); }130 & .d-or[open] > .clone { margin-top: var(--s2); }
114 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }131 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
132+ & #mr-signing code { overflow-wrap: anywhere; }
115 }133 }
116 134
117 /* the rail of a merge request follows the review: it sticks under the135 /* the rail of a merge request follows the review: it sticks under the
118- header, one screen high, and scrolls on its own to its checks, commits136+ header, one screen high, and scrolls on its own to its verdicts. The list of files keeps its own scroll, under a height, so
119- and settings. The list of files keeps its own scroll, under a height, so
120 that the blocks below it stay in reach. Only on a two-column layout. */137 that the blocks below it stay in reach. Only on a two-column layout. */
121 @media (min-width: 1081px) {138 @media (min-width: 1081px) {
122 .rail:has(.r-files) {139 .rail:has(.r-files) {
M internal/adapters/web/static/js/mr.js +54 -1
@@ -49,6 +49,13 @@ window.ermiteMR = (() => {
4949 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
5050 return null;
5151 },
52+ tabIndex(key, index, count) {
53+ if (key === 'Home') return 0;
54+ if (key === 'End') return count - 1;
55+ if (key === 'ArrowRight') return (index + 1) % count;
56+ if (key === 'ArrowLeft') return (index + count - 1) % count;
57+ return null;
58+ },
5259 // step moves an index within [0, n).
5360 step(i, d, n) {
5461 if (n === 0) return -1;
@@ -285,8 +292,54 @@ window.ermiteMR = (() => {
285292 reveal(id);
286293 }
287294
295+ // Enhance the ordinary anchor navigation only when scripts are available.
296+ function panels() {
297+ const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');
298+ if (!footer || !nav) return;
299+ const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
300+ nav.setAttribute('role', 'tablist');
301+ function select(index, focus = false) {
302+ tabs.forEach((tab, i) => {
303+ tab.setAttribute('role', 'tab');
304+ tab.setAttribute('aria-controls', panels[i].id);
305+ tab.setAttribute('aria-selected', String(i === index));
306+ tab.tabIndex = i === index ? 0 : -1;
307+ panels[i].setAttribute('role', 'tabpanel');
308+ panels[i].tabIndex = 0;
309+ panels[i].hidden = i !== index;
310+ });
311+ if (focus) tabs[index].focus();
312+ }
313+ tabs.forEach((tab, i) => {
314+ tab.addEventListener('click', (e) => { e.preventDefault(); select(i); });
315+ tab.addEventListener('keydown', (e) => {
316+ const next = core.tabIndex(e.key, i, tabs.length);
317+ if (next === null) return;
318+ e.preventDefault(); select(next, true);
319+ });
320+ });
321+ function hash() {
322+ const index = location.hash === '#mr-checks' ? 1 : location.hash === '#mr-commits' ? 0 : -1;
323+ if (index < 0) return;
324+ footer.open = true; select(index);
325+ }
326+ // A repeated click on the same hash must also reopen a folded footer.
327+ document.addEventListener('click', (e) => {
328+ if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }
329+ });
330+ const summary = $('#mr-ck');
331+ if (summary) {
332+ const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };
333+ new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true });
334+ sync();
335+ }
336+ select(0); hash();
337+ addEventListener('hashchange', hash);
338+ }
339+
288340 function start() {
289341 if (!$('#diffs[data-mr]')) return;
342+ panels();
290343 let folded = false;
291344 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292345 if (folded) foldFiles(true);
@@ -316,7 +369,7 @@ window.ermiteMR = (() => {
316369 });
317370 }
318371
319- return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
372+ return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
320373 })();
321374
322375 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
@@ -49,6 +49,13 @@ window.ermiteMR = (() => {
49 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };49 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
50 return null;50 return null;
51 },51 },
52+ tabIndex(key, index, count) {
53+ if (key === 'Home') return 0;
54+ if (key === 'End') return count - 1;
55+ if (key === 'ArrowRight') return (index + 1) % count;
56+ if (key === 'ArrowLeft') return (index + count - 1) % count;
57+ return null;
58+ },
52 // step moves an index within [0, n).59 // step moves an index within [0, n).
53 step(i, d, n) {60 step(i, d, n) {
54 if (n === 0) return -1;61 if (n === 0) return -1;
@@ -285,8 +292,54 @@ window.ermiteMR = (() => {
285 reveal(id);292 reveal(id);
286 }293 }
287 294
295+ // Enhance the ordinary anchor navigation only when scripts are available.
296+ function panels() {
297+ const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');
298+ if (!footer || !nav) return;
299+ const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
300+ nav.setAttribute('role', 'tablist');
301+ function select(index, focus = false) {
302+ tabs.forEach((tab, i) => {
303+ tab.setAttribute('role', 'tab');
304+ tab.setAttribute('aria-controls', panels[i].id);
305+ tab.setAttribute('aria-selected', String(i === index));
306+ tab.tabIndex = i === index ? 0 : -1;
307+ panels[i].setAttribute('role', 'tabpanel');
308+ panels[i].tabIndex = 0;
309+ panels[i].hidden = i !== index;
310+ });
311+ if (focus) tabs[index].focus();
312+ }
313+ tabs.forEach((tab, i) => {
314+ tab.addEventListener('click', (e) => { e.preventDefault(); select(i); });
315+ tab.addEventListener('keydown', (e) => {
316+ const next = core.tabIndex(e.key, i, tabs.length);
317+ if (next === null) return;
318+ e.preventDefault(); select(next, true);
319+ });
320+ });
321+ function hash() {
322+ const index = location.hash === '#mr-checks' ? 1 : location.hash === '#mr-commits' ? 0 : -1;
323+ if (index < 0) return;
324+ footer.open = true; select(index);
325+ }
326+ // A repeated click on the same hash must also reopen a folded footer.
327+ document.addEventListener('click', (e) => {
328+ if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }
329+ });
330+ const summary = $('#mr-ck');
331+ if (summary) {
332+ const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };
333+ new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true });
334+ sync();
335+ }
336+ select(0); hash();
337+ addEventListener('hashchange', hash);
338+ }
339+
288 function start() {340 function start() {
289 if (!$('#diffs[data-mr]')) return;341 if (!$('#diffs[data-mr]')) return;
342+ panels();
290 let folded = false;343 let folded = false;
291 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }344 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292 if (folded) foldFiles(true);345 if (folded) foldFiles(true);
@@ -316,7 +369,7 @@ window.ermiteMR = (() => {
316 });369 });
317 }370 }
318 371
319- return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };372+ return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
320 })();373 })();
321 374
322 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());375 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
M internal/app/web_mr_test.go +1 -1
@@ -335,7 +335,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
335335 all := b.get(base, as(token))
336336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
337337 assert.Contains(t, all.body, "since your last read")
338- assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")
338+ assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked")
339339 assert.Contains(t, all.body, "data-read>Mark read")
340340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
341341 since := b.get(base+"/since", as(token))
@@ -335,7 +335,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
335 all := b.get(base, as(token))335 all := b.get(base, as(token))
336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
337 assert.Contains(t, all.body, "since your last read")337 assert.Contains(t, all.body, "since your last read")
338- assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")338+ assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked")
339 assert.Contains(t, all.body, "data-read>Mark read")339 assert.Contains(t, all.body, "data-read>Mark read")
340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
341 since := b.get(base+"/since", as(token))341 since := b.get(base+"/since", as(token))

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 42

↑↓ moveenter openesc close