detached f525ac6

feat(web): turn the MR footer into toggle tabs on a darker background

commit
f525ac63ebda2eef85f9d3a5de537ac635fb7cac
author
brice · · 2026-10-20 08:50 UTC
parent
89e7d82
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +649 -657
M docs/merge-requests.md +7 -5
@@ -107,10 +107,12 @@ because it owns the merge-git port.
107107
108108 ### Review page panels
109109
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
110+The main request card ends with a footer of two tabs, Commits and Checks, both
111+collapsed by default. Clicking a tab opens its panel, clicking the open tab
112+folds it again, clicking the other tab switches. Arrow keys (and Home / End)
113+move between the tabs. Without scripts the tabs are anchor links and the URL
114+fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above
115+the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch
114116 list is capped: the reader's existing count operation counts all reachable
115117 ancestors, not the source-minus-target range.
116118
@@ -123,7 +125,7 @@ merge actions remain restricted to their existing permissions.
123125
124126 A push to the source branch refreshes the web review in place: the diff, files
125127 rail, commits/checks footer, diff range and merge controls follow the new head.
126-The review keeps its visible file/line, folded files and selected footer tab.
128+The review keeps its visible file/line, folded files and open footer panel.
127129 The page request still reads only one 25-file window of hunks; the visible
128130 anchor's window is fetched too if needed, and other windows remain lazy.
129131
@@ -107,10 +107,12 @@ because it owns the merge-git port.
107 107
108 ### Review page panels108 ### Review page panels
109 109
110-The main request card ends with a collapsed Commits / Checks footer. With110+The main request card ends with a footer of two tabs, Commits and Checks, both
111-scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening111+collapsed by default. Clicking a tab opens its panel, clicking the open tab
112-the footer shows both sections. The checks summary above the diff opens the112+folds it again, clicking the other tab switches. Arrow keys (and Home / End)
113-Checks tab and continues to update live. Commit totals use `N+` when the branch113+move between the tabs. Without scripts the tabs are anchor links and the URL
114+fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above
115+the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch
114 list is capped: the reader's existing count operation counts all reachable116 list is capped: the reader's existing count operation counts all reachable
115 ancestors, not the source-minus-target range.117 ancestors, not the source-minus-target range.
116 118
@@ -123,7 +125,7 @@ merge actions remain restricted to their existing permissions.
123 125
124 A push to the source branch refreshes the web review in place: the diff, files126 A push to the source branch refreshes the web review in place: the diff, files
125 rail, commits/checks footer, diff range and merge controls follow the new head.127 rail, commits/checks footer, diff range and merge controls follow the new head.
126-The review keeps its visible file/line, folded files and selected footer tab.128+The review keeps its visible file/line, folded files and open footer panel.
127 The page request still reads only one 25-file window of hunks; the visible129 The page request still reads only one 25-file window of hunks; the visible
128 anchor's window is fetched too if needed, and other windows remain lazy.130 anchor's window is fetched too if needed, and other windows remain lazy.
129 131
M internal/adapters/web/jstest/mr.test.mjs +72 -72
@@ -93,98 +93,98 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
9393 }
9494 });
9595
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 oldHistory = globalThis.history;
101- const listeners = {};
102- const replaced = [];
103- globalThis.history = { replaceState: (...args) => replaced.push(args) };
96+function footerHarness(extra = {}) {
97+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
10498 const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
105- setAttribute(k,v) { this.attrs[k]=v; },
106- addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } });
99+ setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; },
100+ addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; } });
107101 const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
108102 const panels = [node('mr-commits'), node('mr-checks-panel')];
109- const footer = node('mr-panels'); footer.open=false;
110- const nav = node('tabs'); nav.querySelectorAll=()=>tabs;
111- footer.querySelectorAll=()=>panels;
112- globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null,
113- addEventListener(k,v) {listeners[k]=v;} };
114- globalThis.location = {hash:''};
115- globalThis.addEventListener = (k,v) => {listeners[k]=v;};
103+ const footer = node('footer'); footer.querySelectorAll = () => panels;
104+ const nav = node('tabs'); nav.querySelectorAll = () => tabs;
105+ const listeners = {}, replaced = [];
106+ globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
107+ querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
108+ globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
109+ globalThis.addEventListener = (k, v) => { listeners[k] = v; };
110+ globalThis.history = { replaceState: (...args) => replaced.push(args) };
111+ const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } };
112+ const state = () => panels.map((p) => !p.hidden);
113+ return { tabs, panels, listeners, replaced, restore, state };
114+}
115+
116+test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
117+ const h = footerHarness();
116118 try {
117119 window.ermiteMR.panels();
118- assert.equal(footer.open,false);
119- assert.equal(panels[0].hidden,false);
120- assert.equal(panels[1].hidden,true);
121- assert.deepEqual(replaced, [], 'initial selection leaves the URL alone');
122- tabs[1].events.click({preventDefault(){}});
123- assert.deepEqual(replaced, [[null, '', '#mr-checks-panel']]);
124- assert.equal(panels[0].hidden,true);
125- assert.equal(tabs[1].attrs['aria-selected'],'true');
126- tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}});
127- assert.equal(tabs[0].focused,true);
128- assert.equal(panels[0].hidden,false);
129- assert.deepEqual(replaced.at(-1), [null, '', '#mr-commits']);
130- location.hash='#mr-checks'; replaced.length = 0; listeners.hashchange();
131- assert.deepEqual(replaced, [], 'hash navigation does not rewrite the URL');
132- assert.equal(footer.open,true);
133- assert.equal(panels[1].hidden,false);
134- footer.open=false;
135- listeners.click({target:{closest:()=>true}});
136- assert.equal(footer.open,true);
137- assert.equal(C.tabIndex('Home',1,2),0);
138- assert.equal(C.tabIndex('End',0,2),1);
139- assert.equal(C.tabIndex('ArrowLeft',0,2),1);
140- assert.equal(C.tabIndex('Escape',0,2),null);
141- } finally {
142- globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;
143- if (oldHistory === undefined) delete globalThis.history; else globalThis.history = oldHistory;
144- }
120+ assert.deepEqual(h.state(), [false, false], 'initially closed');
121+ assert.equal(h.tabs[0].attrs['aria-expanded'], 'false');
122+ assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone');
123+ h.tabs[1].events.click({ preventDefault() {} });
124+ assert.deepEqual(h.state(), [false, true]);
125+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
126+ assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]);
127+ location.hash = '#mr-checks-panel';
128+ h.tabs[1].events.click({ preventDefault() {} });
129+ assert.deepEqual(h.state(), [false, false], 'a second click closes');
130+ assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment');
131+ location.hash = '';
132+ h.tabs[1].events.click({ preventDefault() {} });
133+ h.tabs[0].events.click({ preventDefault() {} });
134+ assert.deepEqual(h.state(), [true, false], 'the other tab switches');
135+ h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} });
136+ assert.equal(h.tabs[1].focused, true, 'arrows move focus only');
137+ assert.deepEqual(h.state(), [true, false]);
138+ location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange();
139+ assert.deepEqual(h.state(), [false, true], 'hash opens checks');
140+ assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL');
141+ location.hash = '#mr-commits'; h.listeners.hashchange();
142+ assert.deepEqual(h.state(), [true, false]);
143+ assert.equal(C.tabIndex('Home', 1, 2), 0);
144+ assert.equal(C.tabIndex('End', 0, 2), 1);
145+ assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1);
146+ assert.equal(C.tabIndex('Escape', 0, 2), null);
147+ } finally { h.restore(); }
145148 });
146149
147-test('footer: #mr-ck syncs the checks label and a click opens the footer on Checks', () => {
148- const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
149- const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
150- setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, v) { this.events[k] = v; }, focus() {} });
151- const tabs = [node('t0'), node('t1')];
152- const panels = [node('mr-commits'), node('mr-checks-panel')];
153- const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels;
154- let writes = 0, text = '';
155- const label = { get textContent() { return text; }, set textContent(v) { writes++; text = v; } };
156- const nav = node('tabs'); nav.querySelectorAll = () => tabs;
157- nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
150+test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
151+ const text0 = { v: '' }; let writes = 0;
152+ const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
158153 const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
159- const listeners = {};
154+ const h = footerHarness({ summary });
160155 let observed = null, callback = null;
161156 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
162- globalThis.document = { querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? summary : null,
163- addEventListener(k, v) { listeners[k] = v; } };
164- globalThis.location = { hash: '' };
165- globalThis.addEventListener = () => {};
166- globalThis.history = { replaceState() {} };
157+ const nav = document.querySelector('[data-mr-tabs]');
158+ nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
167159 try {
168160 window.ermiteMR.panels();
169161 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
170162 assert.equal(observed[0], summary.parentElement);
171- // A mutation elsewhere in the bar (hx-live rewriting a label) must not
172- // write the label again, or hx-live wakes up on its own write forever.
173163 assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
174164 const before = writes; callback(); callback();
175165 assert.equal(writes, before, 'an unchanged label is not written');
176166 summary.title = ''; callback();
177- assert.equal(label.textContent, '1/2', 'observer re-syncs from the text');
167+ assert.equal(label.textContent, '1/2');
178168 summary.textContent = ' '; callback();
179169 assert.equal(label.textContent, 'no checks');
180- listeners.click({ target: { closest: (s) => s === '#mr-ck' ? summary : null } });
181- assert.equal(footer.open, true);
182- assert.equal(panels[1].hidden, false);
183- assert.equal(panels[0].hidden, true);
184- assert.equal(tabs[1].attrs['aria-selected'], 'true');
185- } finally {
186- for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }
187- }
170+ const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } };
171+ h.listeners.click(click);
172+ assert.deepEqual(h.state(), [false, true]);
173+ h.listeners.click(click);
174+ assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open');
175+ } finally { h.restore(); }
176+});
177+
178+test('footer: panels(index) restores the open panel without touching the URL', () => {
179+ const h = footerHarness();
180+ try {
181+ window.ermiteMR.panels(1);
182+ assert.deepEqual(h.state(), [false, true]);
183+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
184+ assert.deepEqual(h.replaced, []);
185+ window.ermiteMR.panels(-1);
186+ assert.deepEqual(h.state(), [false, false]);
187+ } finally { h.restore(); }
188188 });
189189
190190 test('touch range: first tap anchors, second tap opens the sorted range', () => {
@@ -93,98 +93,98 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
93 }93 }
94 });94 });
95 95
96-test('footer tabs switch panels, support arrows, and open on checks hash', () => {96+function footerHarness(extra = {}) {
97- const oldDocument = globalThis.document;97+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
98- const oldLocation = globalThis.location;
99- const oldAdd = globalThis.addEventListener;
100- const oldHistory = globalThis.history;
101- const listeners = {};
102- const replaced = [];
103- globalThis.history = { replaceState: (...args) => replaced.push(args) };
104 const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,98 const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
105- setAttribute(k,v) { this.attrs[k]=v; },99+ setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; },
106- addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } });100+ addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; } });
107 const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];101 const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
108 const panels = [node('mr-commits'), node('mr-checks-panel')];102 const panels = [node('mr-commits'), node('mr-checks-panel')];
109- const footer = node('mr-panels'); footer.open=false;103+ const footer = node('footer'); footer.querySelectorAll = () => panels;
110- const nav = node('tabs'); nav.querySelectorAll=()=>tabs;104+ const nav = node('tabs'); nav.querySelectorAll = () => tabs;
111- footer.querySelectorAll=()=>panels;105+ const listeners = {}, replaced = [];
112- globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null,106+ globalThis.document = { querySelector: (s) => s === '.mr-footer' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? extra.summary ?? null : null,
113- addEventListener(k,v) {listeners[k]=v;} };107+ querySelectorAll: (s) => s === '[data-mr-tabs] a' ? tabs : [], addEventListener(k, v) { listeners[k] = v; } };
114- globalThis.location = {hash:''};108+ globalThis.location = { hash: '', pathname: '/demo/merge-requests/1', search: '?x=1' };
115- globalThis.addEventListener = (k,v) => {listeners[k]=v;};109+ globalThis.addEventListener = (k, v) => { listeners[k] = v; };
110+ globalThis.history = { replaceState: (...args) => replaced.push(args) };
111+ const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } };
112+ const state = () => panels.map((p) => !p.hidden);
113+ return { tabs, panels, listeners, replaced, restore, state };
114+}
115+
116+test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
117+ const h = footerHarness();
116 try {118 try {
117 window.ermiteMR.panels();119 window.ermiteMR.panels();
118- assert.equal(footer.open,false);120+ assert.deepEqual(h.state(), [false, false], 'initially closed');
119- assert.equal(panels[0].hidden,false);121+ assert.equal(h.tabs[0].attrs['aria-expanded'], 'false');
120- assert.equal(panels[1].hidden,true);122+ assert.deepEqual(h.replaced, [], 'initial state leaves the URL alone');
121- assert.deepEqual(replaced, [], 'initial selection leaves the URL alone');123+ h.tabs[1].events.click({ preventDefault() {} });
122- tabs[1].events.click({preventDefault(){}});124+ assert.deepEqual(h.state(), [false, true]);
123- assert.deepEqual(replaced, [[null, '', '#mr-checks-panel']]);125+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
124- assert.equal(panels[0].hidden,true);126+ assert.deepEqual(h.replaced, [[null, '', '#mr-checks-panel']]);
125- assert.equal(tabs[1].attrs['aria-selected'],'true');127+ location.hash = '#mr-checks-panel';
126- tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}});128+ h.tabs[1].events.click({ preventDefault() {} });
127- assert.equal(tabs[0].focused,true);129+ assert.deepEqual(h.state(), [false, false], 'a second click closes');
128- assert.equal(panels[0].hidden,false);130+ assert.deepEqual(h.replaced.at(-1), [null, '', '/demo/merge-requests/1?x=1'], 'closing clears the fragment');
129- assert.deepEqual(replaced.at(-1), [null, '', '#mr-commits']);131+ location.hash = '';
130- location.hash='#mr-checks'; replaced.length = 0; listeners.hashchange();132+ h.tabs[1].events.click({ preventDefault() {} });
131- assert.deepEqual(replaced, [], 'hash navigation does not rewrite the URL');133+ h.tabs[0].events.click({ preventDefault() {} });
132- assert.equal(footer.open,true);134+ assert.deepEqual(h.state(), [true, false], 'the other tab switches');
133- assert.equal(panels[1].hidden,false);135+ h.tabs[0].events.keydown({ key: 'ArrowRight', preventDefault() {} });
134- footer.open=false;136+ assert.equal(h.tabs[1].focused, true, 'arrows move focus only');
135- listeners.click({target:{closest:()=>true}});137+ assert.deepEqual(h.state(), [true, false]);
136- assert.equal(footer.open,true);138+ location.hash = '#mr-checks'; h.replaced.length = 0; h.listeners.hashchange();
137- assert.equal(C.tabIndex('Home',1,2),0);139+ assert.deepEqual(h.state(), [false, true], 'hash opens checks');
138- assert.equal(C.tabIndex('End',0,2),1);140+ assert.deepEqual(h.replaced, [], 'hash navigation does not rewrite the URL');
139- assert.equal(C.tabIndex('ArrowLeft',0,2),1);141+ location.hash = '#mr-commits'; h.listeners.hashchange();
140- assert.equal(C.tabIndex('Escape',0,2),null);142+ assert.deepEqual(h.state(), [true, false]);
141- } finally {143+ assert.equal(C.tabIndex('Home', 1, 2), 0);
142- globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;144+ assert.equal(C.tabIndex('End', 0, 2), 1);
143- if (oldHistory === undefined) delete globalThis.history; else globalThis.history = oldHistory;145+ assert.equal(C.tabIndex('ArrowLeft', 0, 2), 1);
144- }146+ assert.equal(C.tabIndex('Escape', 0, 2), null);
147+ } finally { h.restore(); }
145 });148 });
146 149
147-test('footer: #mr-ck syncs the checks label and a click opens the footer on Checks', () => {150+test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
148- const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));151+ const text0 = { v: '' }; let writes = 0;
149- const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,152+ const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
150- setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, v) { this.events[k] = v; }, focus() {} });
151- const tabs = [node('t0'), node('t1')];
152- const panels = [node('mr-commits'), node('mr-checks-panel')];
153- const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels;
154- let writes = 0, text = '';
155- const label = { get textContent() { return text; }, set textContent(v) { writes++; text = v; } };
156- const nav = node('tabs'); nav.querySelectorAll = () => tabs;
157- nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
158 const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };153 const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
159- const listeners = {};154+ const h = footerHarness({ summary });
160 let observed = null, callback = null;155 let observed = null, callback = null;
161 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };156 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
162- globalThis.document = { querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? summary : null,157+ const nav = document.querySelector('[data-mr-tabs]');
163- addEventListener(k, v) { listeners[k] = v; } };158+ nav.querySelector = (s) => s === '[data-checks-label]' ? label : null;
164- globalThis.location = { hash: '' };
165- globalThis.addEventListener = () => {};
166- globalThis.history = { replaceState() {} };
167 try {159 try {
168 window.ermiteMR.panels();160 window.ermiteMR.panels();
169 assert.equal(label.textContent, '1/2 failed', 'initial label sync');161 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
170 assert.equal(observed[0], summary.parentElement);162 assert.equal(observed[0], summary.parentElement);
171- // A mutation elsewhere in the bar (hx-live rewriting a label) must not
172- // write the label again, or hx-live wakes up on its own write forever.
173 assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');163 assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
174 const before = writes; callback(); callback();164 const before = writes; callback(); callback();
175 assert.equal(writes, before, 'an unchanged label is not written');165 assert.equal(writes, before, 'an unchanged label is not written');
176 summary.title = ''; callback();166 summary.title = ''; callback();
177- assert.equal(label.textContent, '1/2', 'observer re-syncs from the text');167+ assert.equal(label.textContent, '1/2');
178 summary.textContent = ' '; callback();168 summary.textContent = ' '; callback();
179 assert.equal(label.textContent, 'no checks');169 assert.equal(label.textContent, 'no checks');
180- listeners.click({ target: { closest: (s) => s === '#mr-ck' ? summary : null } });170+ const click = { target: { closest: (s) => s === '#mr-ck' ? summary : null } };
181- assert.equal(footer.open, true);171+ h.listeners.click(click);
182- assert.equal(panels[1].hidden, false);172+ assert.deepEqual(h.state(), [false, true]);
183- assert.equal(panels[0].hidden, true);173+ h.listeners.click(click);
184- assert.equal(tabs[1].attrs['aria-selected'], 'true');174+ assert.deepEqual(h.state(), [false, true], 'a second badge click keeps it open');
185- } finally {175+ } finally { h.restore(); }
186- for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }176+});
187- }177+
178+test('footer: panels(index) restores the open panel without touching the URL', () => {
179+ const h = footerHarness();
180+ try {
181+ window.ermiteMR.panels(1);
182+ assert.deepEqual(h.state(), [false, true]);
183+ assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
184+ assert.deepEqual(h.replaced, []);
185+ window.ermiteMR.panels(-1);
186+ assert.deepEqual(h.state(), [false, false]);
187+ } finally { h.restore(); }
188 });188 });
189 189
190 test('touch range: first tap anchors, second tap opens the sorted range', () => {190 test('touch range: first tap anchors, second tap opens the sorted range', () => {
M internal/adapters/web/mr.templ +51 -54
@@ -378,60 +378,57 @@ templ commentForm(f CommentForm, suffix string) {
378378
379379 templ mrFooter(p MRPage) {
380380 <footer class="mr-footer">
381- <details id="mr-panels">
382- <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary>
383- <nav data-mr-tabs aria-label="Branch details">
384- <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
385- <a id="mr-tab-checks" href="#mr-checks">
386- { "Checks " }
387- <span data-checks-label>
388- if p.ChecksSum.Total > 0 {
389- { p.ChecksSum.Title }
390- } else {
391- no checks
392- }
393- </span>
394- </a>
395- </nav>
396- <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits">
397- <table class="mr-commits">
398- <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
399- <tbody>
400- for _, c := range p.Commits {
401- <tr data-new?={ c.New }>
402- <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
403- <td>{ c.Subject }</td>
404- <td>{ c.Author }</td>
405- <td>
406- if c.WhenAt != "" {
407- <time datetime={ c.WhenAt }>{ c.When }</time>
408- }
409- </td>
410- <td>
411- if c.Sig != nil {
412- @signaturePill(c.Sig)
413- }
414- </td>
415- <td>
416- if c.New {
417- <span>new</span>
418- }
419- </td>
420- </tr>
421- }
422- </tbody>
423- </table>
424- if len(p.Commits) == 0 {
425- <p class="dim">no commit to merge.</p>
426- }
427- if p.CommitsNote != "" {
428- <p class="note">{ p.CommitsNote }</p>
429- }
430- </div>
431- <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks">
432- @MRChecks(p.Checks)
433- </div>
434- </details>
381+ <nav data-mr-tabs aria-label="Branch details">
382+ <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
383+ <a id="mr-tab-checks" href="#mr-checks">
384+ { "Checks " }
385+ <span data-checks-label>
386+ if p.ChecksSum.Total > 0 {
387+ { p.ChecksSum.Title }
388+ } else {
389+ no checks
390+ }
391+ </span>
392+ </a>
393+ </nav>
394+ <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits">
395+ <table class="mr-commits">
396+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
397+ <tbody>
398+ for _, c := range p.Commits {
399+ <tr data-new?={ c.New }>
400+ <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
401+ <td>{ c.Subject }</td>
402+ <td>{ c.Author }</td>
403+ <td>
404+ if c.WhenAt != "" {
405+ <time datetime={ c.WhenAt }>{ c.When }</time>
406+ }
407+ </td>
408+ <td>
409+ if c.Sig != nil {
410+ @signaturePill(c.Sig)
411+ }
412+ </td>
413+ <td>
414+ if c.New {
415+ <span>new</span>
416+ }
417+ </td>
418+ </tr>
419+ }
420+ </tbody>
421+ </table>
422+ if len(p.Commits) == 0 {
423+ <p class="dim">no commit to merge.</p>
424+ }
425+ if p.CommitsNote != "" {
426+ <p class="note">{ p.CommitsNote }</p>
427+ }
428+ </div>
429+ <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks">
430+ @MRChecks(p.Checks)
431+ </div>
435432 </footer>
436433 }
437434
@@ -378,60 +378,57 @@ templ commentForm(f CommentForm, suffix string) {
378 378
379 templ mrFooter(p MRPage) {379 templ mrFooter(p MRPage) {
380 <footer class="mr-footer">380 <footer class="mr-footer">
381- <details id="mr-panels">381+ <nav data-mr-tabs aria-label="Branch details">
382- <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary>382+ <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
383- <nav data-mr-tabs aria-label="Branch details">383+ <a id="mr-tab-checks" href="#mr-checks">
384- <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>384+ { "Checks " }
385- <a id="mr-tab-checks" href="#mr-checks">385+ <span data-checks-label>
386- { "Checks " }386+ if p.ChecksSum.Total > 0 {
387- <span data-checks-label>387+ { p.ChecksSum.Title }
388- if p.ChecksSum.Total > 0 {388+ } else {
389- { p.ChecksSum.Title }389+ no checks
390- } else {390+ }
391- no checks391+ </span>
392- }392+ </a>
393- </span>393+ </nav>
394- </a>394+ <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits">
395- </nav>395+ <table class="mr-commits">
396- <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits">396+ <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
397- <table class="mr-commits">397+ <tbody>
398- <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>398+ for _, c := range p.Commits {
399- <tbody>399+ <tr data-new?={ c.New }>
400- for _, c := range p.Commits {400+ <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>
401- <tr data-new?={ c.New }>401+ <td>{ c.Subject }</td>
402- <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td>402+ <td>{ c.Author }</td>
403- <td>{ c.Subject }</td>403+ <td>
404- <td>{ c.Author }</td>404+ if c.WhenAt != "" {
405- <td>405+ <time datetime={ c.WhenAt }>{ c.When }</time>
406- if c.WhenAt != "" {406+ }
407- <time datetime={ c.WhenAt }>{ c.When }</time>407+ </td>
408- }408+ <td>
409- </td>409+ if c.Sig != nil {
410- <td>410+ @signaturePill(c.Sig)
411- if c.Sig != nil {411+ }
412- @signaturePill(c.Sig)412+ </td>
413- }413+ <td>
414- </td>414+ if c.New {
415- <td>415+ <span>new</span>
416- if c.New {416+ }
417- <span>new</span>417+ </td>
418- }418+ </tr>
419- </td>419+ }
420- </tr>420+ </tbody>
421- }421+ </table>
422- </tbody>422+ if len(p.Commits) == 0 {
423- </table>423+ <p class="dim">no commit to merge.</p>
424- if len(p.Commits) == 0 {424+ }
425- <p class="dim">no commit to merge.</p>425+ if p.CommitsNote != "" {
426- }426+ <p class="note">{ p.CommitsNote }</p>
427- if p.CommitsNote != "" {427+ }
428- <p class="note">{ p.CommitsNote }</p>428+ </div>
429- }429+ <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks">
430- </div>430+ @MRChecks(p.Checks)
431- <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks">431+ </div>
432- @MRChecks(p.Checks)
433- </div>
434- </details>
435 </footer>432 </footer>
436 }433 }
437 434
M internal/adapters/web/mr_templ.go +480 -493 generated file: collapsed
M internal/adapters/web/mr_test.go +5 -1
@@ -134,7 +134,11 @@ func TestMRPanelsAndMenus(t *testing.T) {
134134 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
135135 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
136136 footer := renderToString(t, mrFooter(p))
137- assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`)
137+ assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138+ assert.NotContains(t, footer, `<details`)
139+ assert.NotContains(t, footer, `<summary`)
140+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel hidden`)
141+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel hidden`)
138142 assert.Contains(t, footer, `data-mr-tabs`)
139143 assert.Contains(t, footer, `id="mr-commits"`)
140144 assert.Contains(t, footer, `id="mr-checks"`)
@@ -134,7 +134,11 @@ func TestMRPanelsAndMenus(t *testing.T) {
134 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,134 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
135 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}135 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
136 footer := renderToString(t, mrFooter(p))136 footer := renderToString(t, mrFooter(p))
137- assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`)137+ assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138+ assert.NotContains(t, footer, `<details`)
139+ assert.NotContains(t, footer, `<summary`)
140+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel hidden`)
141+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel hidden`)
138 assert.Contains(t, footer, `data-mr-tabs`)142 assert.Contains(t, footer, `data-mr-tabs`)
139 assert.Contains(t, footer, `id="mr-commits"`)143 assert.Contains(t, footer, `id="mr-commits"`)
140 assert.Contains(t, footer, `id="mr-checks"`)144 assert.Contains(t, footer, `id="mr-checks"`)
M internal/adapters/web/static/css/60-mr.css +8 -3
@@ -10,17 +10,22 @@
1010
1111 .mr-footer {
1212 border-top: 1px solid var(--border); padding: var(--s3) var(--s5);
13+ /* a touch darker than the card body; the card's overflow clips the corners */
14+ background: var(--bg-canvas);
1315 font-size: var(--fs-s);
14- & summary { cursor: pointer; color: var(--ink-dim); }
1516 & 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); }
17+ & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; }
18+ & [data-mr-tabs] a:hover { background: var(--bg-raised); color: var(--ink-hi); }
19+ & [data-mr-tabs] a[aria-expanded="true"] { background: var(--bg-strong); color: var(--ink-hi); }
1820 & [data-mr-panel] { overflow-x: auto; }
1921 & [hidden] { display: none; }
22+ /* Without scripts the panels are shown by the URL fragment alone. */
23+ html:not(.js) & [data-mr-panel][hidden]:is(:target, :has(:target)) { display: block; }
2024 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
2125 & table { width: 100%; border-collapse: collapse; }
2226 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
2327 & th { color: var(--ink-dim); font-weight: 500; }
28+ & tbody tr:last-child > :is(td, th) { border-bottom: 0; }
2429 }
2530 .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }
2631 .merge-settings h2 { font-size: var(--fs-s); }
@@ -10,17 +10,22 @@
10 10
11 .mr-footer {11 .mr-footer {
12 border-top: 1px solid var(--border); padding: var(--s3) var(--s5);12 border-top: 1px solid var(--border); padding: var(--s3) var(--s5);
13+ /* a touch darker than the card body; the card's overflow clips the corners */
14+ background: var(--bg-canvas);
13 font-size: var(--fs-s);15 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 & nav { display: flex; gap: var(--s3); padding-block: var(--s3); }
16- & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); }17+ & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; }
17- & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); }18+ & [data-mr-tabs] a:hover { background: var(--bg-raised); color: var(--ink-hi); }
19+ & [data-mr-tabs] a[aria-expanded="true"] { background: var(--bg-strong); color: var(--ink-hi); }
18 & [data-mr-panel] { overflow-x: auto; }20 & [data-mr-panel] { overflow-x: auto; }
19 & [hidden] { display: none; }21 & [hidden] { display: none; }
22+ /* Without scripts the panels are shown by the URL fragment alone. */
23+ html:not(.js) & [data-mr-panel][hidden]:is(:target, :has(:target)) { display: block; }
20 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }24 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
21 & table { width: 100%; border-collapse: collapse; }25 & table { width: 100%; border-collapse: collapse; }
22 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }26 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
23 & th { color: var(--ink-dim); font-weight: 500; }27 & th { color: var(--ink-dim); font-weight: 500; }
28+ & tbody tr:last-child > :is(td, th) { border-bottom: 0; }
24 }29 }
25 .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }30 .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); }
26 .merge-settings h2 { font-size: var(--fs-s); }31 .merge-settings h2 { font-size: var(--fs-s); }
M internal/adapters/web/static/css/85-mobile.css +0 -2
@@ -49,7 +49,5 @@
4949 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
5050 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
5151 .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }
52- /* No display change: the summary stays a list-item and keeps its marker. */
53- .mr-footer summary { min-height: 40px; line-height: 40px; }
5452 }
5553 }
@@ -49,7 +49,5 @@
49 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }49 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
50 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }50 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51 .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }51 .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }
52- /* No display change: the summary stays a list-item and keeps its marker. */
53- .mr-footer summary { min-height: 40px; line-height: 40px; }
54 }52 }
55 }53 }
M internal/adapters/web/static/js/mr.js +26 -27
@@ -315,42 +315,48 @@ window.ermiteMR = (() => {
315315
316316 // Enhance the ordinary anchor navigation only when scripts are available.
317317 let disposePanels = () => {};
318- function panels() {
318+ // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
319+ // tablist: a tablist always has a selected tab, and here every panel can be
320+ // folded. State: the index of the open panel, or -1 for none.
321+ function panels(initial = -1) {
319322 disposePanels();
320- const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');
323+ const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
321324 if (!footer || !nav) return;
322325 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
323- nav.setAttribute('role', 'tablist');
324- function select(index, focus = false, fromUser = false) {
326+ const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
327+ let current = -1;
328+ // show applies the state; fromUser also keeps the URL fragment in step.
329+ function show(index, fromUser = false) {
330+ current = index;
325331 tabs.forEach((tab, i) => {
326- tab.setAttribute('role', 'tab');
327332 tab.setAttribute('aria-controls', panels[i].id);
328- tab.setAttribute('aria-selected', String(i === index));
329- tab.tabIndex = i === index ? 0 : -1;
330- panels[i].setAttribute('role', 'tabpanel');
331- panels[i].tabIndex = 0;
333+ tab.setAttribute('aria-expanded', String(i === index));
332334 panels[i].hidden = i !== index;
333335 });
334- if (focus) tabs[index].focus();
335- if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
336+ if (!fromUser) return;
337+ if (index >= 0) {
338+ if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
339+ } else if (ids.has(location.hash)) {
340+ history.replaceState(null, '', location.pathname + location.search);
341+ }
336342 }
343+ const toggle = (i) => show(i === current ? -1 : i, true);
337344 tabs.forEach((tab, i) => {
338- tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); });
345+ tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); });
339346 tab.addEventListener('keydown', (e) => {
340347 const next = core.tabIndex(e.key, i, tabs.length);
341348 if (next === null) return;
342- e.preventDefault(); select(next, true, true);
349+ e.preventDefault(); tabs[next].focus();
343350 });
344351 });
345352 function hash() {
346353 const h = location.hash;
347354 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
348- if (index < 0) return;
349- footer.open = true; select(index);
355+ if (index >= 0) show(index);
350356 }
351- // A repeated click on the same hash must also reopen a folded footer.
357+ // The checks badge always opens Checks; it never folds it.
352358 const checksClick = (e) => {
353- if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }
359+ if (e.target.closest?.('#mr-ck')) show(1);
354360 };
355361 document.addEventListener('click', checksClick);
356362 let observer;
@@ -369,7 +375,7 @@ window.ermiteMR = (() => {
369375 observer.observe(summary.parentElement, { childList: true });
370376 sync();
371377 }
372- select(0); hash();
378+ show(initial); hash();
373379 addEventListener('hashchange', hash);
374380 disposePanels = () => {
375381 document.removeEventListener?.('click', checksClick);
@@ -583,8 +589,7 @@ window.ermiteMR = (() => {
583589 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
584590 const folded = $('.r-files')?.hasAttribute('data-folded');
585591 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
586- const footerOpen = $('#mr-panels')?.open;
587- const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
592+ const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
588593 const active = document.activeElement;
589594 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
590595 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
@@ -600,13 +605,7 @@ window.ermiteMR = (() => {
600605 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
601606 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
602607 page = null;
603- panels();
604- if (tab) {
605- const oldURL = location.href;
606- document.getElementById(tab)?.click();
607- if (location.href !== oldURL) history.replaceState(history.state, '', oldURL);
608- }
609- if ($('#mr-panels')) $('#mr-panels').open = footerOpen;
608+ panels(openPanel);
610609 foldFiles(!!folded);
611610 if ($('.rail')) $('.rail').scrollTop = railY;
612611 if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
@@ -315,42 +315,48 @@ window.ermiteMR = (() => {
315 315
316 // Enhance the ordinary anchor navigation only when scripts are available.316 // Enhance the ordinary anchor navigation only when scripts are available.
317 let disposePanels = () => {};317 let disposePanels = () => {};
318- function panels() {318+ // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
319+ // tablist: a tablist always has a selected tab, and here every panel can be
320+ // folded. State: the index of the open panel, or -1 for none.
321+ function panels(initial = -1) {
319 disposePanels();322 disposePanels();
320- const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');323+ const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
321 if (!footer || !nav) return;324 if (!footer || !nav) return;
322 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);325 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
323- nav.setAttribute('role', 'tablist');326+ const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
324- function select(index, focus = false, fromUser = false) {327+ let current = -1;
328+ // show applies the state; fromUser also keeps the URL fragment in step.
329+ function show(index, fromUser = false) {
330+ current = index;
325 tabs.forEach((tab, i) => {331 tabs.forEach((tab, i) => {
326- tab.setAttribute('role', 'tab');
327 tab.setAttribute('aria-controls', panels[i].id);332 tab.setAttribute('aria-controls', panels[i].id);
328- tab.setAttribute('aria-selected', String(i === index));333+ tab.setAttribute('aria-expanded', String(i === index));
329- tab.tabIndex = i === index ? 0 : -1;
330- panels[i].setAttribute('role', 'tabpanel');
331- panels[i].tabIndex = 0;
332 panels[i].hidden = i !== index;334 panels[i].hidden = i !== index;
333 });335 });
334- if (focus) tabs[index].focus();336+ if (!fromUser) return;
335- if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);337+ if (index >= 0) {
338+ if (location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id);
339+ } else if (ids.has(location.hash)) {
340+ history.replaceState(null, '', location.pathname + location.search);
341+ }
336 }342 }
343+ const toggle = (i) => show(i === current ? -1 : i, true);
337 tabs.forEach((tab, i) => {344 tabs.forEach((tab, i) => {
338- tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); });345+ tab.addEventListener('click', (e) => { e.preventDefault(); toggle(i); });
339 tab.addEventListener('keydown', (e) => {346 tab.addEventListener('keydown', (e) => {
340 const next = core.tabIndex(e.key, i, tabs.length);347 const next = core.tabIndex(e.key, i, tabs.length);
341 if (next === null) return;348 if (next === null) return;
342- e.preventDefault(); select(next, true, true);349+ e.preventDefault(); tabs[next].focus();
343 });350 });
344 });351 });
345 function hash() {352 function hash() {
346 const h = location.hash;353 const h = location.hash;
347 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;354 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
348- if (index < 0) return;355+ if (index >= 0) show(index);
349- footer.open = true; select(index);
350 }356 }
351- // A repeated click on the same hash must also reopen a folded footer.357+ // The checks badge always opens Checks; it never folds it.
352 const checksClick = (e) => {358 const checksClick = (e) => {
353- if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }359+ if (e.target.closest?.('#mr-ck')) show(1);
354 };360 };
355 document.addEventListener('click', checksClick);361 document.addEventListener('click', checksClick);
356 let observer;362 let observer;
@@ -369,7 +375,7 @@ window.ermiteMR = (() => {
369 observer.observe(summary.parentElement, { childList: true });375 observer.observe(summary.parentElement, { childList: true });
370 sync();376 sync();
371 }377 }
372- select(0); hash();378+ show(initial); hash();
373 addEventListener('hashchange', hash);379 addEventListener('hashchange', hash);
374 disposePanels = () => {380 disposePanels = () => {
375 document.removeEventListener?.('click', checksClick);381 document.removeEventListener?.('click', checksClick);
@@ -583,8 +589,7 @@ window.ermiteMR = (() => {
583 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));589 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
584 const folded = $('.r-files')?.hasAttribute('data-folded');590 const folded = $('.r-files')?.hasAttribute('data-folded');
585 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;591 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
586- const footerOpen = $('#mr-panels')?.open;592+ const openPanel = $$('[data-mr-tabs] a').findIndex((t) => t.getAttribute('aria-expanded') === 'true');
587- const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
588 const active = document.activeElement;593 const active = document.activeElement;
589 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };594 const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
590 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);595 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
@@ -600,13 +605,7 @@ window.ermiteMR = (() => {
600 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);605 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
601 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;606 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
602 page = null;607 page = null;
603- panels();608+ panels(openPanel);
604- if (tab) {
605- const oldURL = location.href;
606- document.getElementById(tab)?.click();
607- if (location.href !== oldURL) history.replaceState(history.state, '', oldURL);
608- }
609- if ($('#mr-panels')) $('#mr-panels').open = footerOpen;
610 foldFiles(!!folded);609 foldFiles(!!folded);
611 if ($('.rail')) $('.rail').scrollTop = railY;610 if ($('.rail')) $('.rail').scrollTop = railY;
612 if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;611 if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;

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