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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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. | |||
| 16 | 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 | 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 | 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 | 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 | 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 | ||
| @@ -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) { | |||
| 301 | 301 | ||
| 302 | 302 | templ mrRail(p MRPage) { | |
| 303 | 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 | 310 | @FileList(p.Files) | |
| 306 | 311 | </section> | |
| 307 | 312 | @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 @@ | |||
| 20 | 20 | .i-branch { --i: url(icons/branch.svg); } | |
| 21 | 21 | .i-search { --i: url(icons/search.svg); } | |
| 22 | 22 | .i-file { --i: url(icons/file.svg); } | |
| 23 | + | .i-chevron { --i: url(icons/chevron.svg); } | |
| 23 | 24 | .i-tag { --i: url(icons/tag.svg); } | |
| 24 | 25 | .i-user { --i: url(icons/user.svg); } | |
| 25 | 26 | .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 @@ | |||
| 75 | 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 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. */ | |
| 81 | 82 | @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)); | |
| 89 | 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 | 92 | /* on one column the rail goes under the diff: the list of files keeps its | |
| 94 | 93 | own scroll, under a height */ | |
| @@ -97,6 +96,12 @@ | |||
| 97 | 96 | } | |
| 98 | 97 | .r-files { | |
| 99 | 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 | 105 | & > .ffilter { margin: var(--s2) var(--s3) 0; } | |
| 101 | 106 | & > .ffilter input { | |
| 102 | 107 | 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 the | 78 | + | /* 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 with | 79 | + | 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 can | 83 | + | .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 its | 92 | /* 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 @@ | |||
| 9 | 9 | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | |
| 10 | 10 | // submits (the browser does it: the focus is on the button), Esc closes. | |
| 11 | 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 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. | |
| 14 | 15 | // | |
| 15 | 16 | // Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure | |
| 16 | 17 | // (jstest/mr.test.mjs). | |
| @@ -196,6 +197,7 @@ window.ermiteMR = (() => { | |||
| 196 | 197 | // The list of files by keyboard: g f focuses the current file. | |
| 197 | 198 | const links = () => $$('.r-files .files > li:not([hidden]) > a'); | |
| 198 | 199 | function focusFiles() { | |
| 200 | + | foldFiles(false); | |
| 199 | 201 | const all = links(); | |
| 200 | 202 | if (!all.length) return false; | |
| 201 | 203 | const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0]; | |
| @@ -219,6 +221,27 @@ window.ermiteMR = (() => { | |||
| 219 | 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 | 245 | // openFile opens the file of a link of the list (and loads it first). | |
| 223 | 246 | function openFile(id) { | |
| 224 | 247 | const f = document.getElementById(id); | |
| @@ -228,6 +251,9 @@ window.ermiteMR = (() => { | |||
| 228 | 251 | ||
| 229 | 252 | function start() { | |
| 230 | 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 | 257 | watch(); | |
| 232 | 258 | // A link to a file or a comment that is not loaded yet. | |
| 233 | 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 | 274 | 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, Enter | 9 | // 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 opens | 12 | + | // 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 pure | 16 | // 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()); | ||