detached 7ecd377

fix(web): the merge request rail scrolls on its own, and its list of files folds (#53)

commit
7ecd37707358348a023f56fecfe5560bf05d36bd
author
brice · · 2026-10-17 23:38 UTC
committer
bnema · · 2026-10-17 23:38 UTC
parent
55a688e
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
7 files · +112 -74
M internal/adapters/web/ASSETS.md +1 -1
@@ -16,7 +16,7 @@ content-hashed names. Nothing is fetched at runtime.
1616 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1717 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
1818 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
19+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
2020 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
2121 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
2222
@@ -16,7 +16,7 @@ content-hashed names. Nothing is fetched at runtime.
16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
17 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |17 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
18 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |18 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |19+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
20 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |20 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
21 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |21 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
22 22
M internal/adapters/web/mr.templ +6 -1
@@ -301,7 +301,12 @@ templ commentForm(f CommentForm, suffix string) {
301301
302302 templ mrRail(p MRPage) {
303303 <section aria-labelledby="r-files" class="r-files">
304- <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2>
304+ <h2 id="r-files">
305+ Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
306+ <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
307+ <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
308+ </button>
309+ </h2>
305310 @FileList(p.Files)
306311 </section>
307312 @MRChecks(p.Checks)
@@ -301,7 +301,12 @@ templ commentForm(f CommentForm, suffix string) {
301 301
302 templ mrRail(p MRPage) {302 templ mrRail(p MRPage) {
303 <section aria-labelledby="r-files" class="r-files">303 <section aria-labelledby="r-files" class="r-files">
304- <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2>304+ <h2 id="r-files">
305+ Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd>
306+ <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()">
307+ <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span>
308+ </button>
309+ </h2>
305 @FileList(p.Files)310 @FileList(p.Files)
306 </section>311 </section>
307 @MRChecks(p.Checks)312 @MRChecks(p.Checks)
M internal/adapters/web/mr_templ.go +58 -58 generated file: collapsed
M internal/adapters/web/static/css/20-shell.css +1 -0
@@ -20,6 +20,7 @@
2020 .i-branch { --i: url(icons/branch.svg); }
2121 .i-search { --i: url(icons/search.svg); }
2222 .i-file { --i: url(icons/file.svg); }
23+ .i-chevron { --i: url(icons/chevron.svg); }
2324 .i-tag { --i: url(icons/tag.svg); }
2425 .i-user { --i: url(icons/user.svg); }
2526 .i-key { --i: url(icons/key.svg); }
@@ -20,6 +20,7 @@
20 .i-branch { --i: url(icons/branch.svg); }20 .i-branch { --i: url(icons/branch.svg); }
21 .i-search { --i: url(icons/search.svg); }21 .i-search { --i: url(icons/search.svg); }
22 .i-file { --i: url(icons/file.svg); }22 .i-file { --i: url(icons/file.svg); }
23+ .i-chevron { --i: url(icons/chevron.svg); }
23 .i-tag { --i: url(icons/tag.svg); }24 .i-tag { --i: url(icons/tag.svg); }
24 .i-user { --i: url(icons/user.svg); }25 .i-user { --i: url(icons/user.svg); }
25 .i-key { --i: url(icons/key.svg); }26 .i-key { --i: url(icons/key.svg); }
M internal/adapters/web/static/css/60-mr.css +16 -11
@@ -75,20 +75,19 @@
7575 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
7676 }
7777
78- /* the rail of a merge request: the list of files on top, which follows the
79- review and scrolls on its own; checks, commits and settings scroll with
80- the page. Only on a two-column layout. */
78+ /* the rail of a merge request follows the review: it sticks under the
79+ header, one screen high, and scrolls on its own to its checks, commits
80+ and settings. The list of files keeps its own scroll, under a height, so
81+ that the blocks below it stay in reach. Only on a two-column layout. */
8182 @media (min-width: 1081px) {
82- /* the rail runs the height of the content, so that its first block can
83- stick all along the diff */
84- .work.has-rail:has(.r-files) { align-items: stretch; }
85- .rail:has(.r-files) { align-self: stretch; }
86- .rail .r-files {
87- position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); z-index: 3;
88- display: flex; flex-direction: column;
83+ .rail:has(.r-files) {
84+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4));
8985 max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);
86+ overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
9087 }
91- .rail .r-files > .files { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; min-height: 0; }
88+ /* the blocks keep their height: the rail scrolls, it does not squeeze them */
89+ .rail:has(.r-files) > * { flex: none; }
90+ .rail .r-files > .files { max-height: 55dvh; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
9291 }
9392 /* on one column the rail goes under the diff: the list of files keeps its
9493 own scroll, under a height */
@@ -97,6 +96,12 @@
9796 }
9897 .r-files {
9998 & > h2 kbd { margin-left: auto; }
99+ & > h2 .fold { width: 24px; height: 24px; margin: -2px -6px -2px 0; background: none; border-color: transparent; color: var(--ink-dim); }
100+ & > h2 .fold:hover { color: var(--ink-hi); border-color: var(--border-strong); }
101+ & > h2 .fold .ico { transition: transform .15s; }
102+ &[data-folded] > h2 { border-bottom: 0; }
103+ &[data-folded] > h2 .fold .ico { transform: rotate(-90deg); }
104+ &[data-folded] > :not(h2) { display: none; }
100105 & > .ffilter { margin: var(--s2) var(--s3) 0; }
101106 & > .ffilter input {
102107 width: 100%; height: 30px; padding: 0 var(--s2); font: inherit; font-size: var(--fs-s); color: inherit;
@@ -75,20 +75,19 @@
75 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }75 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
76 }76 }
77 77
78- /* the rail of a merge request: the list of files on top, which follows the78+ /* the rail of a merge request follows the review: it sticks under the
79- review and scrolls on its own; checks, commits and settings scroll with79+ header, one screen high, and scrolls on its own to its checks, commits
80- the page. Only on a two-column layout. */80+ and settings. The list of files keeps its own scroll, under a height, so
81+ that the blocks below it stay in reach. Only on a two-column layout. */
81 @media (min-width: 1081px) {82 @media (min-width: 1081px) {
82- /* the rail runs the height of the content, so that its first block can83+ .rail:has(.r-files) {
83- stick all along the diff */84+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4));
84- .work.has-rail:has(.r-files) { align-items: stretch; }
85- .rail:has(.r-files) { align-self: stretch; }
86- .rail .r-files {
87- position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); z-index: 3;
88- display: flex; flex-direction: column;
89 max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);85 max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);
86+ overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
90 }87 }
91- .rail .r-files > .files { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; min-height: 0; }88+ /* the blocks keep their height: the rail scrolls, it does not squeeze them */
89+ .rail:has(.r-files) > * { flex: none; }
90+ .rail .r-files > .files { max-height: 55dvh; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
92 }91 }
93 /* on one column the rail goes under the diff: the list of files keeps its92 /* on one column the rail goes under the diff: the list of files keeps its
94 own scroll, under a height */93 own scroll, under a height */
@@ -97,6 +96,12 @@
97 }96 }
98 .r-files {97 .r-files {
99 & > h2 kbd { margin-left: auto; }98 & > h2 kbd { margin-left: auto; }
99+ & > h2 .fold { width: 24px; height: 24px; margin: -2px -6px -2px 0; background: none; border-color: transparent; color: var(--ink-dim); }
100+ & > h2 .fold:hover { color: var(--ink-hi); border-color: var(--border-strong); }
101+ & > h2 .fold .ico { transition: transform .15s; }
102+ &[data-folded] > h2 { border-bottom: 0; }
103+ &[data-folded] > h2 .fold .ico { transform: rotate(-90deg); }
104+ &[data-folded] > :not(h2) { display: none; }
100 & > .ffilter { margin: var(--s2) var(--s3) 0; }105 & > .ffilter { margin: var(--s2) var(--s3) 0; }
101 & > .ffilter input {106 & > .ffilter input {
102 width: 100%; height: 30px; padding: 0 var(--s2); font: inherit; font-size: var(--fs-s); color: inherit;107 width: 100%; height: 30px; padding: 0 var(--s2); font: inherit; font-size: var(--fs-s); color: inherit;
A internal/adapters/web/static/icons/chevron.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
M internal/adapters/web/static/js/mr.js +29 -3
@@ -9,8 +9,9 @@
99 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
1010 // submits (the browser does it: the focus is on the button), Esc closes.
1111 // R marks the head read. Nothing merges on one key.
12-// 3. g f puts the focus in the list of files; j / k move in it, Enter opens
13-// the file, Esc goes back to the diff.
12+// 3. g f puts the focus in the list of files (opening it when folded); j / k
13+// move in it, Enter opens the file, Esc goes back to the diff. The button
14+// of its header folds the list, and the choice is remembered.
1415 //
1516 // Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure
1617 // (jstest/mr.test.mjs).
@@ -196,6 +197,7 @@ window.ermiteMR = (() => {
196197 // The list of files by keyboard: g f focuses the current file.
197198 const links = () => $$('.r-files .files > li:not([hidden]) > a');
198199 function focusFiles() {
200+ foldFiles(false);
199201 const all = links();
200202 if (!all.length) return false;
201203 const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
@@ -219,6 +221,27 @@ window.ermiteMR = (() => {
219221 return true;
220222 }
221223
224+ // foldFiles folds the list of files to its header, or opens it again:
225+ // toggles without an argument. The choice is kept for the next pages.
226+ function foldFiles(fold) {
227+ const sec = $('.r-files');
228+ const btn = sec && $('button.fold', sec);
229+ if (!btn) return;
230+ if (fold === undefined) fold = !sec.hasAttribute('data-folded');
231+ sec.toggleAttribute('data-folded', fold);
232+ btn.setAttribute('aria-expanded', String(!fold));
233+ btn.title = fold ? 'open the list of files' : 'fold the list of files';
234+ $('.sr', btn).textContent = btn.title;
235+ // The list did not follow the review while folded: show its current file,
236+ // moving the list only (never the page).
237+ const box = !fold && $('.files', sec);
238+ const a = box && $('a[aria-current="true"]', box);
239+ if (a && (a.offsetTop < box.scrollTop || a.offsetTop + a.offsetHeight > box.scrollTop + box.clientHeight)) {
240+ box.scrollTop = a.offsetTop - (box.clientHeight - a.offsetHeight) / 2;
241+ }
242+ try { localStorage.setItem('mr-files-folded', fold ? '1' : ''); } catch { /* storage refused: holds for this page */ }
243+ }
244+
222245 // openFile opens the file of a link of the list (and loads it first).
223246 function openFile(id) {
224247 const f = document.getElementById(id);
@@ -228,6 +251,9 @@ window.ermiteMR = (() => {
228251
229252 function start() {
230253 if (!$('#diffs[data-mr]')) return;
254+ let folded = false;
255+ try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
256+ if (folded) foldFiles(true);
231257 watch();
232258 // A link to a file or a comment that is not loaded yet.
233259 const hash = () => {
@@ -242,7 +268,7 @@ window.ermiteMR = (() => {
242268 });
243269 }
244270
245- return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile };
271+ return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
246272 })();
247273
248274 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
@@ -9,8 +9,9 @@
9 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter9 // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter
10 // submits (the browser does it: the focus is on the button), Esc closes.10 // submits (the browser does it: the focus is on the button), Esc closes.
11 // R marks the head read. Nothing merges on one key.11 // R marks the head read. Nothing merges on one key.
12-// 3. g f puts the focus in the list of files; j / k move in it, Enter opens12+// 3. g f puts the focus in the list of files (opening it when folded); j / k
13-// the file, Esc goes back to the diff.13+// move in it, Enter opens the file, Esc goes back to the diff. The button
14+// of its header folds the list, and the choice is remembered.
14 //15 //
15 // Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure16 // Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure
16 // (jstest/mr.test.mjs).17 // (jstest/mr.test.mjs).
@@ -196,6 +197,7 @@ window.ermiteMR = (() => {
196 // The list of files by keyboard: g f focuses the current file.197 // The list of files by keyboard: g f focuses the current file.
197 const links = () => $$('.r-files .files > li:not([hidden]) > a');198 const links = () => $$('.r-files .files > li:not([hidden]) > a');
198 function focusFiles() {199 function focusFiles() {
200+ foldFiles(false);
199 const all = links();201 const all = links();
200 if (!all.length) return false;202 if (!all.length) return false;
201 const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];203 const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0];
@@ -219,6 +221,27 @@ window.ermiteMR = (() => {
219 return true;221 return true;
220 }222 }
221 223
224+ // foldFiles folds the list of files to its header, or opens it again:
225+ // toggles without an argument. The choice is kept for the next pages.
226+ function foldFiles(fold) {
227+ const sec = $('.r-files');
228+ const btn = sec && $('button.fold', sec);
229+ if (!btn) return;
230+ if (fold === undefined) fold = !sec.hasAttribute('data-folded');
231+ sec.toggleAttribute('data-folded', fold);
232+ btn.setAttribute('aria-expanded', String(!fold));
233+ btn.title = fold ? 'open the list of files' : 'fold the list of files';
234+ $('.sr', btn).textContent = btn.title;
235+ // The list did not follow the review while folded: show its current file,
236+ // moving the list only (never the page).
237+ const box = !fold && $('.files', sec);
238+ const a = box && $('a[aria-current="true"]', box);
239+ if (a && (a.offsetTop < box.scrollTop || a.offsetTop + a.offsetHeight > box.scrollTop + box.clientHeight)) {
240+ box.scrollTop = a.offsetTop - (box.clientHeight - a.offsetHeight) / 2;
241+ }
242+ try { localStorage.setItem('mr-files-folded', fold ? '1' : ''); } catch { /* storage refused: holds for this page */ }
243+ }
244+
222 // openFile opens the file of a link of the list (and loads it first).245 // openFile opens the file of a link of the list (and loads it first).
223 function openFile(id) {246 function openFile(id) {
224 const f = document.getElementById(id);247 const f = document.getElementById(id);
@@ -228,6 +251,9 @@ window.ermiteMR = (() => {
228 251
229 function start() {252 function start() {
230 if (!$('#diffs[data-mr]')) return;253 if (!$('#diffs[data-mr]')) return;
254+ let folded = false;
255+ try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
256+ if (folded) foldFiles(true);
231 watch();257 watch();
232 // A link to a file or a comment that is not loaded yet.258 // A link to a file or a comment that is not loaded yet.
233 const hash = () => {259 const hash = () => {
@@ -242,7 +268,7 @@ window.ermiteMR = (() => {
242 });268 });
243 }269 }
244 270
245- return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile };271+ return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
246 })();272 })();
247 273
248 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());274 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());

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 44

↑↓ moveenter openesc close