detached a01252a

fix(web): keep the open MR footer panel through an in-place refresh

commit
a01252ad02639c972907fdd5f0f483b84d23a2fd
author
brice · · 2026-10-20 09:03 UTC
parent
6b81fd4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
3/4
4 files · +33 -5
M internal/adapters/web/jstest/mr.test.mjs +22 -3
@@ -97,7 +97,8 @@ function footerHarness(extra = {}) {
9797 const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
9898 const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
9999 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; } });
100+ addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; },
101+ scrollIntoView() { this.scrolled = (this.scrolled ?? 0) + 1; } });
101102 const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
102103 const panels = [node('mr-commits'), node('mr-checks-panel')];
103104 const footer = node('footer'); footer.querySelectorAll = () => panels;
@@ -110,7 +111,7 @@ function footerHarness(extra = {}) {
110111 globalThis.history = { replaceState: (...args) => replaced.push(args) };
111112 const restore = () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } };
112113 const state = () => panels.map((p) => !p.hidden);
113- return { tabs, panels, listeners, replaced, restore, state };
114+ return { tabs, panels, footer, listeners, replaced, restore, state };
114115 }
115116
116117 test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
@@ -178,15 +179,33 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
178179 test('footer: panels(index) restores the open panel without touching the URL', () => {
179180 const h = footerHarness();
180181 try {
182+ location.hash = '#mr-commits';
181183 window.ermiteMR.panels(1);
182- assert.deepEqual(h.state(), [false, true]);
184+ assert.deepEqual(h.state(), [false, true], 'a restore ignores the fragment');
183185 assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
184186 assert.deepEqual(h.replaced, []);
187+ assert.equal(h.panels[0].scrolled, undefined, 'a restore does not scroll');
188+ assert.equal(h.panels[1].scrolled, undefined);
189+ location.hash = '';
185190 window.ermiteMR.panels(-1);
186191 assert.deepEqual(h.state(), [false, false]);
187192 } finally { h.restore(); }
188193 });
189194
195+test('footer: scripts take over the panels; a fragment opens and scrolls to its panel', () => {
196+ const h = footerHarness();
197+ try {
198+ location.hash = '#mr-checks';
199+ window.ermiteMR.panels();
200+ assert.equal(h.footer.attrs['data-panels'], '', 'the CSS fallback of the fragment stops');
201+ assert.deepEqual(h.state(), [false, true]);
202+ assert.equal(h.panels[1].scrolled, 1, 'the panel of the fragment is brought in');
203+ location.hash = '#mr-commits'; h.listeners.hashchange();
204+ assert.deepEqual(h.state(), [true, false]);
205+ assert.equal(h.panels[0].scrolled, 1);
206+ } finally { h.restore(); }
207+});
208+
190209 test('touch range: first tap anchors, second tap opens the sorted range', () => {
191210 const base = 'https://forge.test/demo/merge-requests/1';
192211 const first = base + '?on=new:24:a.go#f1';
@@ -97,7 +97,8 @@ function footerHarness(extra = {}) {
97 const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));97 const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]]));
98 const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,98 const node = (id) => ({ id, attrs: {}, events: {}, hidden: false,
99 setAttribute(k, v) { this.attrs[k] = v; }, getAttribute(k) { return this.attrs[k] ?? null; },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; } });100+ addEventListener(k, v) { this.events[k] = v; }, focus() { this.focused = true; },
101+ scrollIntoView() { this.scrolled = (this.scrolled ?? 0) + 1; } });
101 const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];102 const tabs = [node('mr-tab-commits'), node('mr-tab-checks')];
102 const panels = [node('mr-commits'), node('mr-checks-panel')];103 const panels = [node('mr-commits'), node('mr-checks-panel')];
103 const footer = node('footer'); footer.querySelectorAll = () => panels;104 const footer = node('footer'); footer.querySelectorAll = () => panels;
@@ -110,7 +111,7 @@ function footerHarness(extra = {}) {
110 globalThis.history = { replaceState: (...args) => replaced.push(args) };111 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 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 const state = () => panels.map((p) => !p.hidden);
113- return { tabs, panels, listeners, replaced, restore, state };114+ return { tabs, panels, footer, listeners, replaced, restore, state };
114 }115 }
115 116
116 test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {117 test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
@@ -178,15 +179,33 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
178 test('footer: panels(index) restores the open panel without touching the URL', () => {179 test('footer: panels(index) restores the open panel without touching the URL', () => {
179 const h = footerHarness();180 const h = footerHarness();
180 try {181 try {
182+ location.hash = '#mr-commits';
181 window.ermiteMR.panels(1);183 window.ermiteMR.panels(1);
182- assert.deepEqual(h.state(), [false, true]);184+ assert.deepEqual(h.state(), [false, true], 'a restore ignores the fragment');
183 assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');185 assert.equal(h.tabs[1].attrs['aria-expanded'], 'true');
184 assert.deepEqual(h.replaced, []);186 assert.deepEqual(h.replaced, []);
187+ assert.equal(h.panels[0].scrolled, undefined, 'a restore does not scroll');
188+ assert.equal(h.panels[1].scrolled, undefined);
189+ location.hash = '';
185 window.ermiteMR.panels(-1);190 window.ermiteMR.panels(-1);
186 assert.deepEqual(h.state(), [false, false]);191 assert.deepEqual(h.state(), [false, false]);
187 } finally { h.restore(); }192 } finally { h.restore(); }
188 });193 });
189 194
195+test('footer: scripts take over the panels; a fragment opens and scrolls to its panel', () => {
196+ const h = footerHarness();
197+ try {
198+ location.hash = '#mr-checks';
199+ window.ermiteMR.panels();
200+ assert.equal(h.footer.attrs['data-panels'], '', 'the CSS fallback of the fragment stops');
201+ assert.deepEqual(h.state(), [false, true]);
202+ assert.equal(h.panels[1].scrolled, 1, 'the panel of the fragment is brought in');
203+ location.hash = '#mr-commits'; h.listeners.hashchange();
204+ assert.deepEqual(h.state(), [true, false]);
205+ assert.equal(h.panels[0].scrolled, 1);
206+ } finally { h.restore(); }
207+});
208+
190 test('touch range: first tap anchors, second tap opens the sorted range', () => {209 test('touch range: first tap anchors, second tap opens the sorted range', () => {
191 const base = 'https://forge.test/demo/merge-requests/1';210 const base = 'https://forge.test/demo/merge-requests/1';
192 const first = base + '?on=new:24:a.go#f1';211 const first = base + '?on=new:24:a.go#f1';
M internal/adapters/web/mr_test.go +3 -0
@@ -142,6 +142,9 @@ func TestMRPanelsAndMenus(t *testing.T) {
142142 assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143143 assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144144 assert.NotContains(t, footer, `data-mr-panel hidden`)
145+ css, err := staticFS.ReadFile("static/css/60-mr.css")
146+ require.NoError(t, err)
147+ assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
145148 assert.Contains(t, footer, `data-mr-tabs`)
146149 assert.Contains(t, footer, `id="mr-commits"`)
147150 assert.Contains(t, footer, `id="mr-checks"`)
@@ -142,6 +142,9 @@ func TestMRPanelsAndMenus(t *testing.T) {
142 assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)142 assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143 assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)143 assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144 assert.NotContains(t, footer, `data-mr-panel hidden`)144 assert.NotContains(t, footer, `data-mr-panel hidden`)
145+ css, err := staticFS.ReadFile("static/css/60-mr.css")
146+ require.NoError(t, err)
147+ assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
145 assert.Contains(t, footer, `data-mr-tabs`)148 assert.Contains(t, footer, `data-mr-tabs`)
146 assert.Contains(t, footer, `id="mr-commits"`)149 assert.Contains(t, footer, `id="mr-commits"`)
147 assert.Contains(t, footer, `id="mr-checks"`)150 assert.Contains(t, footer, `id="mr-checks"`)
M internal/adapters/web/static/css/60-mr.css +1 -2
@@ -25,8 +25,7 @@
2525 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
2626 & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
2727 & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28- & [data-mr-panel] { padding-block-start: var(--s2); }
29- & [data-mr-panel] { overflow-x: auto; }
28+ & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
3029 & [hidden] { display: none; }
3130 /* Until mr.js drives the panels (data-panels, then [hidden]), only the
3231 panel of the URL fragment shows: the tabs work as anchors without
@@ -25,8 +25,7 @@
25 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }25 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
26 & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }26 & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
27 & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }27 & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28- & [data-mr-panel] { padding-block-start: var(--s2); }28+ & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
29- & [data-mr-panel] { overflow-x: auto; }
30 & [hidden] { display: none; }29 & [hidden] { display: none; }
31 /* Until mr.js drives the panels (data-panels, then [hidden]), only the30 /* Until mr.js drives the panels (data-panels, then [hidden]), only the
32 panel of the URL fragment shows: the tabs work as anchors without31 panel of the URL fragment shows: the tabs work as anchors without
M internal/adapters/web/static/js/mr.js +7 -0
@@ -600,6 +600,13 @@ window.ermiteMR = (() => {
600600 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601601 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
602602 $('#diffs', page).dataset.mrStarted = 'true';
603+ // The fresh footer comes in with the panels as they are, so that an open
604+ // panel does not fold and open again while the other regions swap.
605+ const freshFooter = $('.mr-footer', page);
606+ if (freshFooter) {
607+ freshFooter.setAttribute('data-panels', '');
608+ $$('[data-mr-panel]', freshFooter).forEach((p, i) => { p.hidden = i !== openPanel; });
609+ }
603610 for (const sel of regions) {
604611 const fresh = $(sel, page);
605612 await replaceRegion($(sel), fresh);
@@ -600,6 +600,13 @@ window.ermiteMR = (() => {
600 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);600 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
601 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();601 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
602 $('#diffs', page).dataset.mrStarted = 'true';602 $('#diffs', page).dataset.mrStarted = 'true';
603+ // The fresh footer comes in with the panels as they are, so that an open
604+ // panel does not fold and open again while the other regions swap.
605+ const freshFooter = $('.mr-footer', page);
606+ if (freshFooter) {
607+ freshFooter.setAttribute('data-panels', '');
608+ $$('[data-mr-panel]', freshFooter).forEach((p, i) => { p.hidden = i !== openPanel; });
609+ }
603 for (const sel of regions) {610 for (const sel of regions) {
604 const fresh = $(sel, page);611 const fresh = $(sel, page);
605 await replaceRegion($(sel), fresh);612 await replaceRegion($(sel), fresh);

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