detached 966bf44

Merge branch 'feat/mr-view-ux-panels' into feat/mr-view-ux

commit
966bf44718c393f5c509309cad92736b6791bb3d
author
brice · · 2026-10-20 05:11 UTC
merge of
cc7ba64 + 42e48d0 · diff against the first parent
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 45

↑↓ moveenter openesc close