main 1fb2421

ermite.js RawHistoryFolder

1// ermite.js: the behaviour of the pages that htmx and hx-live do not give.
2//
3//  1. Keys. A global key *sequence* state machine ("g" then "h") and the
4//     keystrokes aimed at inputs cannot be declared with hx-on. The keydown
5//     handler only translates keys into DOM events named "k-<name>" on <body>;
6//     keys.templ binds each of them (hx-on, one line each) to a function of
7//     window.ermite below, which does the work: row cursor, dialogs, filters,
8//     diff layout, permalink.
9//  2. Clicks on [data-fire] elements fire the same events (header buttons).
10//  3. ermiteLive applies the fragments of the event stream (see events.go).
11//  4. The file of the diff in view is marked in the table of contents.
12//  5. A handler that fails is shown in the hint badge.
13//  6. The lines a diff hides load in place (ermiteGaps; e and E).
14//  7. The service worker (ermiteInstall).
15//  8. Chip lists kept on one line, with a … chip (ermiteFit).
16//  9. The layout of the home page, remembered in the browser (ermiteLayout).
17// 10. The wide mode: every page takes the whole width (ermiteWide).
18// 11. The theme switch of the footer (ermiteThemeSwitch).
19//
20// Preferences are kept by ermiteStore (store.js, loaded first).
21(() => {
22  // Marks the page as scripted: what does nothing without scripts (the filters,
23  // the "go to" and help buttons) is hidden until this class is set (see the
24  // utilities of 10-base.css).
25  document.documentElement.classList.add('js');
26
27  const $ = (sel, root = document) => root.querySelector(sel);
28  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
29  const body = () => document.body;
30
31  // flash shows a message in the hint badge (body[data-flash], a status
32  // region), then clears it unless another message came in the meantime.
33  let flashTimer = 0;
34  const flash = (msg, ms = 2000) => {
35    body().dataset.flash = msg;
36    clearTimeout(flashTimer);
37    flashTimer = setTimeout(() => (body().dataset.flash = ''), ms);
38  };
39
40  // Rail tables are context, not the main row cursor.
41  const visibleRows = () => $$('[data-rows] > :not([hidden])').filter((r) => !r.closest('.rail'));
42  // setCursor puts data-cursor on el and takes it from the others.
43  const setCursor = (el, others) => {
44    for (const o of others) if (o !== el) o.removeAttribute('data-cursor');
45    el.setAttribute('data-cursor', '');
46  };
47  const scrollPage = (to) => {
48    scrollTo(0, to);
49    $('#main')?.scrollTo(0, to);
50  };
51
52  const ermite = {
53    flash,
54    fire(name) {
55      body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
56    },
57
58    // Row cursor: j / k. In the side panel they move the focus between its
59    // controls instead.
60    move(d) {
61      if ($('dialog[open]')) return;
62      if (window.ermiteMR?.stepFiles(d) || window.ermiteRail.step(d)) return;
63      const rows = visibleRows();
64      if (!rows.length) {
65        scrollBy(0, d * 180);
66        $('#main')?.scrollBy(0, d * 180);
67        return;
68      }
69      const i = rows.findIndex((r) => r.hasAttribute('data-cursor'));
70      const j = Math.max(0, Math.min(rows.length - 1, i + d));
71      setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
72      rows[j].scrollIntoView({ block: 'nearest' });
73      window.ermiteTree?.sync(rows[j]);
74      window.ermiteFinder?.sync(); // the rows may be the file finder's options
75      const mark = j + 1 + '/' + rows.length;
76      body().dataset.nav = mark;
77      setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
78    },
79    // gg / G: first or last row, or the top or bottom of the page.
80    edge(last) {
81      const rows = visibleRows();
82      const row = last ? rows.at(-1) : rows[0];
83      if (!row) return scrollPage(last ? 1e9 : 0);
84      setCursor(row, $$('[data-rows] > [data-cursor]'));
85      row.scrollIntoView({ block: 'nearest' });
86      window.ermiteTree?.sync(row);
87      window.ermiteFinder?.sync();
88    },
89    open() {
90      if ($('dialog[open]') || window.ermiteTree?.open()) return;
91      $('[data-cursor] a')?.click();
92    },
93    // l / h: in the Files view, open a directory or a file / close a directory
94    // or go up. Elsewhere: into the side panel (the current file of a merge
95    // request, else its main action, else its first control) and back to the
96    // content. Enter then activates the control.
97    rail(into) {
98      if ($('dialog[open]')) return;
99      if (window.ermiteTree?.key(into)) return;
100      if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back');
101      if (!into && window.ermiteMR?.leaveFiles()) return;
102      const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave();
103      if (msg) flash(msg);
104    },
105    // openFile opens a file of the diff the table of contents links to.
106    openFile(id) {
107      const f = document.getElementById(id);
108      if (f) f.open = true;
109    },
110    focusFilter() {
111      $('[data-filter]')?.focus();
112    },
113    go(sel) {
114      $(sel)?.click();
115    },
116    tab(key) {
117      $(`.tabs [data-key="g ${key}"]`)?.click();
118    },
119
120    // The preference "single-key shortcuts" (ermiteKeys): the checkbox of the
121    // help dialog and the action of the palette.
122    setKeysOff(off) {
123      window.ermiteKeys.set(off);
124    },
125    toggleKeys() {
126      const off = !window.ermiteKeys.off();
127      window.ermiteKeys.set(off);
128      ermite.closeDialogs();
129      flash(off ? 'single-key shortcuts off · ctrl k and esc still work' : 'single-key shortcuts on');
130    },
131
132    // Dialogs.
133    closeDialogs() {
134      for (const d of $$('dialog[open]')) d.close();
135    },
136    dialog(id, focusId) {
137      const d = document.getElementById(id);
138      if (!d) return;
139      const opener = document.activeElement;
140      ermite.closeDialogs();
141      d.addEventListener('close', () => {
142        // close events are queued: a replacement dialog may already be open.
143        if (!document.querySelector('dialog[open]') && opener?.isConnected) opener.focus();
144      }, { once: true });
145      d.showModal();
146      if (id === 'panel-cmd') window.ermiteCmd?.sync();
147      if (focusId) document.getElementById(focusId)?.focus();
148    },
149
150    // filter hides the rows that do not contain the text of an input. The
151    // input says which rows (data-filter, or data-pick for a list without a
152    // cursor) and, optionally, the groups to hide when they have no row left
153    // (data-groups). The cursor goes to the first row left.
154    filter(input) {
155      const rows = ermite.hide(input);
156      if (input.dataset.filter) {
157        const first = rows.find((r) => !r.hidden);
158        for (const r of rows) r.toggleAttribute('data-cursor', r === first);
159      }
160    },
161    // hide applies the text of a filter input to its rows and groups, without
162    // moving the cursor, and returns the rows.
163    hide(input) {
164      const t = input.value.toLowerCase();
165      // A bare data-filter marks a field with its own search (the file finder
166      // of the Files view): there are no rows to hide.
167      const sel = input.dataset.filter || input.dataset.pick;
168      if (!sel) return [];
169      const rows = $$(sel);
170      for (const r of rows) {
171        const was = r.hidden;
172        r.hidden = !r.textContent.toLowerCase().includes(t);
173        // A row shown again lays out its chips again (it had no width hidden).
174        if (was && !r.hidden) for (const ul of $$('[data-fit]', r)) window.ermiteFit?.fit(ul);
175      }
176      if (input.dataset.groups) {
177        for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
178      }
179      return rows;
180    },
181    // The "go to" palette (ermiteCmd below).
182    cmdFilter(input) {
183      window.ermiteCmd.filter(input);
184    },
185    cmdKey(e) {
186      window.ermiteCmd.key(e);
187    },
188
189    // Diff.
190    layout(mode) {
191      const el = $('[data-diff]');
192      if (!el) return;
193      if (el.hasAttribute('data-mr') && el.dataset.mobile === 'true') return;
194      el.dataset.diff = mode;
195      try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
196      for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true;
197    },
198    collapse() {
199      const files = $$('[data-file]');
200      const open = !files.some((d) => d.open);
201      for (const d of files) d.open = open;
202    },
203    // e / E: the whole file in view, or every file of the diff, with every
204    // line its hunks hide.
205    expandFile() {
206      const f = window.ermiteGaps.current();
207      if (!f) return flash('no file in view');
208      f.open = true;
209      window.ermiteGaps.file(f).then((r) => {
210        if (!r.done) return; // load said what failed
211        flash(r.lines ? 'whole file' : 'nothing hidden in this file');
212      });
213    },
214    async expandAll() {
215      const files = $$('[data-file]').filter((f) => f.querySelector('tr.gap'));
216      if (!files.length) return flash('nothing hidden in the loaded files');
217      flash('loading every line…', 10000);
218      for (const f of files) {
219        if (!(await window.ermiteGaps.file(f)).done) return; // load said what failed
220      }
221      flash('every line of the loaded files');
222    },
223    nextNote() {
224      const notes = $$('#diffs .cmt');
225      if (!notes.length) return;
226      const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3);
227      const n = notes[i < 0 ? 0 : i];
228      const d = n.closest('details');
229      if (d) d.open = true;
230      n.scrollIntoView({ block: 'center' });
231    },
232
233    // y: pin the URL to the commit and copy it.
234    async permalink() {
235      const b = body().dataset;
236      if (!b.sha) return flash('no ref to pin on this page');
237      const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
238      // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>.
239      const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname;
240      if (path === location.pathname) return flash('this page has no ref to pin');
241      history.replaceState(null, '', path + location.hash);
242      try {
243        await navigator.clipboard.writeText(location.href);
244        flash('permalink ' + b.sha.slice(0, 7) + ' copied');
245      } catch {
246        flash('permalink in the address bar (copy refused)');
247      }
248    },
249
250    // The [copy] button of a clone line: the label says "Copied" for 1.5 s,
251    // and so does the accessible name; the hint badge (a status region)
252    // announces it.
253    async copy(btn) {
254      const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? '';
255      btn.dataset.idleName ||= btn.getAttribute('aria-label') || '';
256      let ok = true;
257      try { await navigator.clipboard.writeText(line); } catch { ok = false; }
258      btn.textContent = ok ? 'Copied' : 'Copy failed';
259      btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line);
260      flash(ok ? 'copied' : 'copy failed', 1500);
261      clearTimeout(btn._restore);
262      btn._restore = setTimeout(() => {
263        btn.textContent = btn.dataset.label;
264        btn.setAttribute('aria-label', btn.dataset.idleName);
265      }, 1500);
266    },
267  };
268  window.ermite = ermite;
269
270  // The "go to" palette is an editable combobox with a listbox popup: the
271  // focus stays in #cmd-q, the options are the <li> of #cmd-list, and the one
272  // with data-cursor is the active one (aria-selected on it, aria-activedescendant
273  // on the field). sync keeps the two in step; it runs on every change of the
274  // cursor or of the list, including the file hits files.js adds later.
275  // Self-contained: jstest/palette.test.mjs evaluates this block alone.
276  window.ermiteCmd = (() => {
277    const list = () => document.getElementById('cmd-list');
278    const field = () => document.getElementById('cmd-q');
279    const options = () => [...list().querySelectorAll(':scope > li')];
280    let seq = 0;
281    const moveCursor = (li) => {
282      for (const o of options()) if (o !== li) o.removeAttribute('data-cursor');
283      li.setAttribute('data-cursor', '');
284    };
285    function sync() {
286      const input = field();
287      if (!input || !list()) return;
288      const all = options();
289      const shown = all.filter((li) => !li.hidden);
290      const cur = shown.find((li) => li.hasAttribute('data-cursor'));
291      for (const li of all) {
292        li.id ||= 'cmd-h' + seq++;
293        li.setAttribute('role', 'option');
294        li.setAttribute('aria-selected', li === cur ? 'true' : 'false');
295      }
296      input.setAttribute('aria-expanded', shown.length ? 'true' : 'false');
297      if (cur) input.setAttribute('aria-activedescendant', cur.id);
298      else input.removeAttribute('aria-activedescendant');
299    }
300    return {
301      sync,
302      // filter hides the entries that do not contain the text of the field.
303      // The file hits the finder adds (li.finder-hit, files.js) have no data-k:
304      // the finder owns them.
305      filter(input) {
306        const t = input.value.toLowerCase().trim();
307        const items = options().filter((li) => li.hasAttribute('data-k'));
308        for (const li of items) li.hidden = !li.getAttribute('data-k').includes(t);
309        const first = items.find((li) => !li.hidden);
310        if (first) moveCursor(first);
311        sync();
312      },
313      key(e) {
314        const vis = options().filter((li) => !li.hidden);
315        const i = vis.findIndex((li) => li.hasAttribute('data-cursor'));
316        const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
317        if (step) {
318          e.preventDefault();
319          const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))];
320          if (n) {
321            moveCursor(n);
322            n.scrollIntoView({ block: 'nearest' });
323          }
324          sync();
325        } else if (e.key === 'Enter') {
326          e.preventDefault();
327          vis[Math.max(i, 0)]?.querySelector('a')?.click();
328        }
329      },
330    };
331  })();
332
333  // The side panel (aside.rail) by keyboard: l enters it on its main action
334  // (the primary button) or its first control, j / k step between its shown
335  // controls (not hidden, not in a closed <details>), h leaves it for <main>.
336  // Each returns the hint to flash, if any. Self-contained: jstest/rail.test.mjs
337  // evaluates this block alone.
338  window.ermiteRail = (() => {
339    const CONTROLS = ['a', 'button', 'select', 'summary', 'input', 'textarea'];
340    const reachable = (e) => {
341      if (e.disabled || e.closest('[hidden]')) return false;
342      if (e.tagName === 'A' && !e.hasAttribute('href')) return false;
343      if (e.tagName === 'INPUT' && e.getAttribute('type') === 'hidden') return false;
344      return !e.getClientRects || e.getClientRects().length > 0;
345    };
346    const rail = () => document.querySelector('.rail');
347    // items walks the panel in document order.
348    const items = (r) => {
349      const out = [];
350      const walk = (e) => {
351        for (const c of e.children) {
352          if (CONTROLS.includes(c.tagName.toLowerCase())) { if (reachable(c)) out.push(c); }
353          else walk(c);
354        }
355      };
356      walk(r);
357      return out;
358    };
359    const inside = (r) => r && r.contains(document.activeElement);
360    const go = (e) => { e.focus(); e.scrollIntoView({ block: 'nearest' }); };
361    return {
362      enter() {
363        const r = rail();
364        if (!r) return 'no side panel on this page';
365        const list = items(r);
366        const target = list.find((e) => e.matches('.btn.primary')) || list[0];
367        if (!target) return 'nothing to act on in the side panel';
368        go(target);
369        return 'side panel · j k move · enter act · h back';
370      },
371      leave() {
372        if (inside(rail())) document.getElementById('main')?.focus({ preventScroll: true });
373      },
374      // step moves d controls within the panel; false when the focus is not in it.
375      step(d) {
376        const r = rail();
377        if (!inside(r)) return false;
378        const list = items(r);
379        const i = list.indexOf(document.activeElement);
380        const next = list[Math.max(0, Math.min(list.length - 1, i + d))];
381        if (next) go(next);
382        return true;
383      },
384    };
385  })();
386
387  // The installed app: the service worker (/-/sw.js) is registered once the
388  // page has loaded. With it and the manifest the browser offers the install
389  // by itself (the address bar of a desktop, the menu or a banner on a phone);
390  // the page leaves that offer alone. Self-contained: jstest/install.test.mjs
391  // evaluates this block alone.
392  window.ermiteInstall = (() => {
393    const api = {
394      // register installs the service worker; a browser without one, or a
395      // refusal, leaves the site working as before.
396      register() {
397        if (!navigator.serviceWorker) return Promise.resolve(null);
398        return navigator.serviceWorker.register('/-/sw.js', { scope: '/' }).catch(() => null);
399      },
400    };
401    if (document.readyState === 'complete') api.register();
402    else addEventListener('load', () => api.register(), { once: true });
403    return api;
404  })();
405
406  // The hidden lines of a diff: a row .gap holds a button that loads them
407  // (/context); the rows that come back replace the gap in the table of each
408  // layout (the unified and the split tables hold one row each for it). A
409  // file whose lines are all loaded has no gap left.
410  window.ermiteGaps = (() => {
411    const pending = new Map();
412    // selectLoaded marks the loaded rows that fall in the range of lines the
413    // URL selects (?on=new:21-149:a.go, a shift + click), as the server marks
414    // the rows it sent: the range stays one block once its hidden lines show.
415    // The loaded rows are context lines, the same on both sides.
416    function selectLoaded(file, rows) {
417      const m = /^(new|old):(?:(\d+)-)?(\d+):(.+)$/.exec(new URLSearchParams(location.search).get('on') || '');
418      // data-file is the path comments name (the new path of a rename), as ?on=
419      if (!m || file.dataset.file !== m[4]) return;
420      const end = Number(m[3]);
421      const start = m[2] ? Number(m[2]) : end;
422      for (const r of rows) {
423        const cells = [...r.querySelectorAll('td')];
424        // unified: old, new, mark, text; split: old side, then new side
425        const num = Number((cells.length === 4 ? cells[m[1] === 'old' ? 0 : 1] : cells[m[1] === 'old' ? 0 : 3])?.textContent.trim());
426        if (!(num >= start && num <= end)) continue;
427        if (cells.length === 4) r.classList.add('sel');
428        else for (const c of cells) c.classList.add('sel');
429      }
430    }
431    // load fills the gap the button is in, in both tables of its file.
432    function load(btn) {
433      const src = btn.dataset.src;
434      const row = btn.closest('tr.gap');
435      const file = row?.closest('[data-file]');
436      if (!src || !file) return Promise.resolve(0);
437      if (pending.has(src)) return pending.get(src);
438      const key = row.dataset.gap;
439      const table = btn.closest('table');
440      const focused = document.activeElement === btn;
441      btn.disabled = true;
442      const p = fetch(src, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
443        .then(async (res) => {
444          if (!res.ok) throw new Error('loading the hidden lines: ' + res.status);
445          const t = document.createElement('template');
446          t.innerHTML = await res.text();
447          let n = 0;
448          for (const table of file.querySelectorAll('table.dt')) {
449            const kind = table.classList.contains('spl') ? 'spl' : 'uni';
450            const from = t.content.querySelector('tbody[data-for="' + kind + '"]');
451            const at = [...table.querySelectorAll('tr.gap')].find((r) => r.dataset.gap === key);
452            if (!from || !at) continue;
453            const rows = [...from.children].map((r) => r.cloneNode(true));
454            n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
455            selectLoaded(file, rows.filter((r) => !r.classList.contains('gap')));
456            at.replaceWith(...rows);
457          }
458          // The focus was on the button, which is gone: on the gap that is
459          // left in the same table, else on the content.
460          if (focused && (document.activeElement === btn || document.activeElement === document.body)) {
461            const next = [...(table?.querySelectorAll('tr.gap') || [])].find((r) => r.dataset.gap === key)?.querySelector('button');
462            if (next) next.focus({ preventScroll: true });
463            else document.getElementById('main')?.focus({ preventScroll: true });
464          }
465          return n;
466        })
467        .catch((err) => {
468          btn.disabled = false;
469          window.ermite?.flash?.(err.message, 4000);
470          return -1;
471        })
472        .finally(() => pending.delete(src));
473      pending.set(src, p);
474      return p;
475    }
476    // file loads every gap of a file, until none is left (a long gap comes
477    // back with a gap for the rest). A gap already loading is waited for.
478    // done is false when a load failed (it said why) or the file never ran
479    // out of gaps.
480    async function file(f) {
481      let lines = 0;
482      for (let guard = 0; guard < 1000; guard++) {
483        const btn = f.querySelector('tr.gap button[data-src]');
484        if (!btn) return { done: true, lines };
485        const n = await load(btn);
486        if (n < 0) return { done: false, lines };
487        if (btn.isConnected) {
488          // an answer that did not take the gap's place: stop, not loop
489          window.ermite?.flash?.('some hidden lines could not be shown', 4000);
490          return { done: false, lines };
491        }
492        lines += n;
493      }
494      window.ermite?.flash?.('the file is too long to show every line at once', 4000);
495      return { done: false, lines };
496    }
497    // current is the file in view: the one the table of contents marks, else
498    // the last whose top is above the top third of the window.
499    function current() {
500      const a = document.querySelector('.files a[aria-current="true"]');
501      const marked = a && document.getElementById(a.hash.slice(1));
502      if (marked?.matches('[data-file]')) return marked;
503      const files = [...document.querySelectorAll('[data-file]')];
504      return files.findLast((f) => f.getBoundingClientRect().top < innerHeight / 3) ?? files[0] ?? null;
505    }
506    addEventListener('click', (e) => {
507      const btn = e.target.closest?.('tr.gap button[data-src]');
508      if (btn) load(btn);
509    });
510    return { load, file, current };
511  })();
512
513  // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys
514  // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu,
515  // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows
516  // keep working. The choice is kept by ermiteStore ("keys-off": '1' when off)
517  // and is made in the help dialog (a checkbox) or the palette (an action).
518  // Self-contained with store.js: jstest/a11y.test.mjs evaluates this block.
519  window.ermiteKeys = (() => {
520    const pref = window.ermiteStore.pref('keys-off', { values: ['1', ''], fallback: '' });
521    const isOff = () => pref.get() === '1';
522    // apply shows the state: the checkbox and the note of the help dialog,
523    // and the label of the palette action.
524    function apply() {
525      const off = isOff();
526      const box = document.getElementById('keys-off');
527      if (box) box.checked = off;
528      const note = document.getElementById('keys-note');
529      if (note) {
530        note.textContent = off
531          ? 'Single-key shortcuts are off: only ctrl k and esc, enter and the arrows work. Turn them on again here.'
532          : 'Single-key shortcuts are on. ctrl k and esc work whatever this says.';
533      }
534      const act = document.querySelector('#cmd-list [data-fire="keys"] .lbl');
535      if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off';
536    }
537    function set(v) {
538      pref.set(v ? '1' : '');
539      apply();
540    }
541    pref.watch(apply);
542    apply();
543    return {
544      off: isOff,
545      set,
546      apply,
547      // blocks says whether the preference turns this key off.
548      blocks: (e) => isOff() && e.key.length === 1,
549    };
550  })();
551
552  // The layout of the home page: "cards" (the default), "split" (two
553  // repositories per row) or "compact" (one line each). The choice is kept by
554  // ermiteStore ("home-layout"), made with the buttons [data-set-layout] of
555  // the layout switch, and shown as .home[data-layout], which the styles
556  // follow, and as aria-pressed on the buttons. Another tab's change applies
557  // here at once. The topic chips are fitted again: their room changes with the
558  // layout. Self-contained with store.js: jstest/layout.test.mjs evaluates
559  // this block.
560  window.ermiteLayout = (() => {
561    const values = ['cards', 'split', 'compact'];
562    const pref = window.ermiteStore.pref('home-layout', { values, fallback: 'cards' });
563    function apply() {
564      const layout = pref.get();
565      const home = document.querySelector('.home');
566      if (home) home.setAttribute('data-layout', layout);
567      for (const b of document.querySelectorAll('[data-set-layout]')) {
568        b.setAttribute('aria-pressed', String(b.getAttribute('data-set-layout') === layout));
569      }
570      window.ermiteFit?.all();
571    }
572    function set(v) {
573      pref.set(v);
574      apply();
575    }
576    addEventListener('click', (e) => {
577      const btn = e.target.closest?.('[data-set-layout]');
578      if (btn) set(btn.getAttribute('data-set-layout'));
579    });
580    pref.watch(apply);
581    apply();
582    return { get: pref.get, set, apply, values };
583  })();
584
585  // The wide mode (w, on every page): the work area takes the whole width of
586  // the screen, the rail at the right edge and the content in the rest.
587  // The choice is kept by ermiteStore ("wide": '1' when wide) and shown
588  // as html.wide, which the styles follow. Another tab's change applies here
589  // at once.
590  window.ermiteWide = (() => {
591    const pref = window.ermiteStore.pref('wide', { values: ['1', ''], fallback: '' });
592    function apply() {
593      const wide = pref.get() === '1';
594      document.documentElement.classList.toggle?.('wide', wide);
595    }
596    function toggle() {
597      pref.set(pref.get() === '1' ? '' : '1');
598      apply();
599    }
600    pref.watch(apply);
601    apply();
602    return { toggle, apply };
603  })();
604
605  // The theme switch: the footer button [data-theme-switch] cycles the theme
606  // of ermiteTheme (store.js, which applies it before the first paint):
607  // auto → light → dark → auto. Its icon, label and title say the current
608  // mode and the next one; a click flashes the new mode in the hint (a live
609  // region). Another tab's change applies here at once, and on a page back
610  // from the back/forward cache.
611  // Self-contained with store.js: jstest/theme.test.mjs evaluates this block.
612  window.ermiteThemeSwitch = (() => {
613    const T = window.ermiteTheme;
614    const icons = { auto: 'i-sun-moon', light: 'i-sun', dark: 'i-moon' };
615    function apply() {
616      const mode = T.apply();
617      const label = `theme: ${mode} (switch to ${T.next(mode)})`;
618      for (const b of document.querySelectorAll('[data-theme-switch]')) {
619        b.setAttribute('aria-label', label);
620        b.setAttribute('title', label);
621        const ico = b.querySelector('.ico');
622        if (ico) ico.className = 'ico ' + icons[mode];
623      }
624    }
625    function cycle() {
626      const mode = T.next(T.get());
627      T.set(mode);
628      apply();
629      window.ermite?.flash('theme: ' + mode);
630    }
631    addEventListener('click', (e) => {
632      if (e.target.closest?.('[data-theme-switch]')) cycle();
633    });
634    addEventListener('pageshow', (e) => { if (e.persisted) apply(); });
635    T.watch(apply);
636    apply();
637    return { cycle, apply };
638  })();
639
640  // The menus of the bars (<details class="menu">: the diff view, the merge
641  // menu): closing one hands the focus back to its button when it was inside,
642  // so that a keyboard user keeps their place.
643  function closeMenu(d) {
644    const had = d.contains(document.activeElement);
645    d.open = false;
646    if (had) $('summary', d)?.focus();
647  }
648  // closeMenus closes every open menu (escape); true when one was open.
649  function closeMenus() {
650    const open = $$('details.menu[open]');
651    for (const d of open) closeMenu(d);
652    return open.length > 0;
653  }
654
655  // 1. Keys.
656  const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
657    I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
658    // the merge request page (mr.js): the merge menu, mark read
659    M: 'merge', R: 'read' };
660  // Section keys are the second letter of the g sequence.
661  const goto = { h: 'gh', s: 's', f: 'f', c: 'c', b: 'b', m: 'm', i: 'i', r: 'r', a: 'a', g: 'top' };
662  let pending = 0;
663  let pendingTimer = 0;
664  addEventListener('keydown', (e) => {
665    const typing = e.target.closest('input:not([type=checkbox], [type=radio], [type=submit], [type=button]), textarea, select, [contenteditable]');
666    // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
667    // field keeps ctrl k (kill to end of line on macOS).
668    if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); }
669    if (e.ctrlKey || e.metaKey || e.altKey) return;
670    const mr = window.ermiteMR;
671    if (e.key === 'Escape') {
672      pending = 0;
673      if (closeMenus() || mr?.leaveFiles()) return;
674      if (typing) e.target.blur();
675      return ermite.fire('esc');
676    }
677    // Single-character shortcuts turned off: only the keys above work.
678    if (window.ermiteKeys.blocks(e)) { pending = 0; return; }
679    // An open merge menu takes its keys (1-9, D) before the field check: its
680    // radios and its checkbox hold the focus.
681    if (mr?.menuKey(e)) return;
682    if (typing) return;
683    // An open dialog takes only the keys that move between dialogs: the page
684    // behind it does not react (scripted clicks would go through inert).
685    if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
686    if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
687    // Some layouts report lowercase e.key even with Shift. Uppercase actions
688    // also interrupt a pending g rather than being swallowed by it.
689    const actionKey = e.shiftKey ? e.key.toUpperCase() : e.key;
690    if (['G', 'M', 'R', 'E', 'I'].includes(actionKey)) {
691      pending = 0;
692      if (actionKey === 'I' && !$('[data-inbox]')) return;
693      e.preventDefault();
694      return ermite.fire(single[actionKey]);
695    }
696    if (pending) {
697      const name = goto[e.key];
698      pending = 0;
699      if (name) { e.preventDefault(); ermite.fire(name); }
700      return;
701    }
702    if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }
703    // The arrows and Home / End are the tree's only while the focus is on one of
704    // its rows (they scroll, or move the focus, anywhere else); j k l h are
705    // page-level keys, as on the other lists.
706    if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }
707    const name = single[e.key];
708    if (!name || (name === 'inbox' && !$('[data-inbox]'))) return;
709    if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
710    ermite.fire(name);
711  });
712
713  // 2. A [data-fire] element does what the key of that name does (the header
714  // buttons, the diff layout links). The link to the refs page works without
715  // scripts and is upgraded here to open the dialog; a click with a modifier
716  // keeps its meaning (new tab).
717  addEventListener('click', (e) => {
718    const el = e.target.closest?.('[data-fire]');
719    if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
720    e.preventDefault();
721    ermite.fire(el.dataset.fire);
722    // A choice in a menu closes it; the focus goes back to its button.
723    const menu = el.closest('details.menu');
724    if (menu) closeMenu(menu);
725  });
726  // Listbox options own the interaction; their anchors are presentation only.
727  // Keyboard activation also clicks this wrapper (palette, finder and j/k).
728  addEventListener('click', (e) => {
729    const target = e.target.closest?.('a[data-href]');
730    const list = target?.closest?.('[role="listbox"]');
731    if (!list || !['cmd-list', 'finder-list'].includes(list.id) || e.button) return;
732    e.preventDefault();
733    location.assign(target.dataset.href);
734  });
735
736  // A click outside an open menu closes it.
737  addEventListener('click', (e) => {
738    for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
739  });
740  for (const a of $$('a[data-fire="refs"]')) {
741    a.setAttribute('aria-haspopup', 'dialog');
742    a.setAttribute('aria-controls', 'panel-refs');
743  }
744
745  // A login link signs in without a click: its page submits its form once a
746  // browser shows it, not while it is prerendered.
747  const auto = $('form[data-autosubmit]');
748  if (auto) {
749    const go = () => auto.requestSubmit();
750    if (document.prerendering) document.addEventListener('prerenderingchange', go, { once: true });
751    else go();
752  }
753
754  // In the app shell the page does not scroll, <main> does: give it focus so arrows,
755  // PageUp/PageDown and space scroll the content without a click first.
756  if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
757
758  // 4. The table of contents of a diff marks the file in view: the last file
759  // in the top third of the window, else the last file whose top is above that
760  // line (the first file when none is). One observer tracks the files in the
761  // band; it reports only when a file enters or leaves it, so a scroll costs
762  // nothing in between, and a jump that skips the band is caught by the scan.
763  let files = $$('.dfile');
764  const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
765  if (files.length && links.size && 'IntersectionObserver' in window) {
766    const inBand = new Set();
767    let current = null;
768    const mark = () => {
769      const f = files.findLast((x) => inBand.has(x))
770        ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3)
771        ?? files[0];
772      if (f === current) return;
773      links.get(current?.id)?.removeAttribute('aria-current');
774      const a = links.get(f.id);
775      a?.setAttribute('aria-current', 'true');
776      // The list scrolls on its own: the current file stays in it. Only the
777      // list moves, never the page (scrollIntoView would scroll the page to
778      // the list when the rail is under the diff).
779      const box = a?.closest('.files');
780      if (a && box && box.scrollHeight > box.clientHeight && !box.contains(document.activeElement)) {
781        const top = a.offsetTop;
782        if (top < box.scrollTop) box.scrollTop = top;
783        else if (top + a.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + a.offsetHeight - box.clientHeight;
784      }
785      current = f;
786    };
787    const io = new IntersectionObserver((entries) => {
788      for (const e of entries) {
789        if (e.isIntersecting) inBand.add(e.target);
790        else inBand.delete(e.target);
791      }
792      mark();
793    }, { rootMargin: '0px 0px -67% 0px' });
794    for (const f of files) io.observe(f);
795    // A refreshed MR replaced the rail and the windowed diff. Rebuild once,
796    // not once per file (which would make a large file list quadratic).
797    ermite.refreshFiles = () => {
798      io.disconnect(); inBand.clear(); current = null;
799      files = $$('.dfile'); links.clear();
800      for (const a of $$('.files a[href^="#"]')) links.set(a.hash.slice(1), a);
801      for (const f of files) io.observe(f);
802    };
803    // A window of files of a merge request came in (mr.js): its files replace
804    // placeholders, which the observer and the list knew by element.
805    ermite.watchFile = (f, old) => {
806      if (old) { io.unobserve(old); inBand.delete(old); }
807      files = $$('.dfile');
808      for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
809      if (current && !current.isConnected) current = null;
810      io.observe(f);
811    };
812  }
813
814  // 8. A list of chips on one line ([data-fit], the topics of a home row):
815  // the chips that wrap out of sight are hidden and a "…" button after the
816  // last one shown names them; pressing it opens the list (data-open) on as
817  // many lines as it needs. Self-contained: jstest/fit.test.mjs evaluates
818  // this block alone.
819  window.ermiteFit = (() => {
820    const fit = (ul) => {
821      if (ul.hasAttribute('data-open')) return;
822      // The … button is kept across fits, so that a refit (a resize) does not
823      // take the focus from it.
824      let more = ul.querySelector(':scope > li.more');
825      let btn = more?.firstElementChild;
826      const focused = !!btn && document.activeElement === btn;
827      more?.remove();
828      const chips = [...ul.children];
829      for (const li of chips) li.hidden = false;
830      const top = chips[0]?.offsetTop;
831      if (top === undefined || chips.at(-1).offsetTop === top) {
832        // Everything fits now: a focused … button hands the focus to the
833        // last chip.
834        if (focused && chips.length) {
835          chips.at(-1).setAttribute('tabindex', '-1');
836          chips.at(-1).focus({ preventScroll: true });
837        }
838        return;
839      }
840      if (!more) {
841        more = document.createElement('li');
842        more.className = 'more';
843        btn = document.createElement('button');
844        btn.type = 'button';
845        btn.textContent = '…';
846        more.append(btn);
847      }
848      ul.append(more);
849      if (focused) btn.focus({ preventScroll: true });
850      // Keep the chips of the first line, then drop from the end until the
851      // … chip fits there too.
852      let shown = chips.findIndex((li) => li.offsetTop !== top);
853      for (const li of chips.slice(shown)) li.hidden = true;
854      while (shown > 0 && more.offsetTop !== top) chips[--shown].hidden = true;
855      const rest = chips.slice(shown).map((li) => li.textContent);
856      btn.title = rest.join(', ');
857      btn.setAttribute('aria-label', 'show ' + rest.length + ' more: ' + rest.join(', '));
858    };
859    // open shows every chip of the list of the … button pressed.
860    const open = (btn) => {
861      const ul = btn.closest('[data-fit]');
862      if (!ul) return;
863      const item = btn.closest('li');
864      // The focus goes to the first chip that was hidden, now shown.
865      const next = [...ul.children].find((li) => li.hidden);
866      ul.setAttribute('data-open', '');
867      item.remove();
868      for (const li of ul.children) li.hidden = false;
869      if (next) {
870        next.setAttribute('tabindex', '-1');
871        next.focus({ preventScroll: true });
872      }
873    };
874    const all = (root = document) => {
875      for (const ul of root.querySelectorAll('[data-fit]')) fit(ul);
876    };
877    return { fit, all, open };
878  })();
879  addEventListener('click', (e) => {
880    const btn = e.target.closest?.('[data-fit] > li.more > button');
881    if (btn) window.ermiteFit.open(btn);
882  });
883  {
884    window.ermiteFit.all();
885    // Widths change with the window and when the fonts arrive; one frame per
886    // burst. Always listening: a live update can bring the first list.
887    let pending = 0;
888    const again = () => {
889      if (pending) return;
890      pending = requestAnimationFrame(() => { pending = 0; window.ermiteFit.all(); });
891    };
892    addEventListener('resize', again);
893    document.fonts?.ready.then(again);
894  }
895
896  // 5. A failure of a handler or of a request (htmx logs it to the console
897  // already) is shown, so that a key that does nothing is not a mystery.
898  addEventListener('htmx:error', (e) => {
899    if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused")
900    const err = e.detail?.error;
901    flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000);
902  });
903})();
904
905// ermiteLive applies the fragments of the event stream (/-/events, see
906// internal/adapters/web/events.go). A fragment is a top-level element with an
907// id: it replaces the element of the page that has the same id. A fragment
908// marked data-gone removes it; a row of a repository the home page does not
909// list yet is added at the top of the list. Activity updates also move existing
910// home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a
911// banner, a persistent status region) keeps its element and only gets the
912// children of the fragment, so that screen readers announce the change. A gap,
913// which says the page may have missed events or that what it shows changed,
914// reloads the page. A stream the server refuses on reconnection (the
915// repository is gone, the session ended) makes the page reload.
916window.ermiteLive = (() => {
917  let connection = null;
918  window.addEventListener?.('htmx:sse:after:connection', (event) => {
919    if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
920  });
921  let reloading = false;
922  let stopped = false;
923  // A stream that drops and comes back within LOST_AFTER (a proxy or the
924  // server closing it, a network hiccup) is not worth a message.
925  const LOST_AFTER = 5000;
926  let lostTimer = 0;
927  // The focus survives a swap. focusables are the controls of a fragment in
928  // document order; where(root) says which one has the focus (its id, else its
929  // tag and href or name, and its index among the controls) and refocus finds
930  // it again in the element that replaced root.
931  const FOCUSABLE = 'a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]';
932  // shown: not inside a hidden element (a filtered row, a folded block); the
933  // browser refuses the focus there.
934  const shown = (e) => !e.closest('[hidden]') && (!e.checkVisibility || e.checkVisibility());
935  const focusables = (root) => [...root.querySelectorAll(FOCUSABLE)].filter(shown);
936  // take focuses e and says whether the focus is there now.
937  const take = (e) => { e.focus({ preventScroll: true }); return document.activeElement === e; };
938  const where = (root) => {
939    const a = document.activeElement;
940    if (!a || a === document.body || !root.contains(a)) return null;
941    if (a === root) return { self: true };
942    const all = focusables(root);
943    return { id: a.id, tag: a.tagName, href: a.getAttribute('href'), name: a.getAttribute('name'), index: all.indexOf(a) };
944  };
945  const refocus = (root, w) => {
946    if (w.self) return root.focus({ preventScroll: true });
947    const all = focusables(root);
948    const same = (e) => e.tagName === w.tag;
949    const target =
950      (w.id && all.find((e) => e.id === w.id)) ||
951      (w.href != null && all.find((e) => same(e) && e.getAttribute('href') === w.href)) ||
952      (w.name != null && all.find((e) => same(e) && e.getAttribute('name') === w.name)) ||
953      (all[w.index] && same(all[w.index]) && all[w.index]) ||
954      all[0];
955    if (target) target.focus({ preventScroll: true });
956    else if (root.hasAttribute('tabindex')) root.focus({ preventScroll: true });
957  };
958  // lostFocus: the element that had the focus inside is gone. The focus goes to
959  // the first control of the next shown row, else of the nearest shown row
960  // before, else to the content.
961  const lostFocus = (after, before) => {
962    const tryRow = (e) => {
963      if (!shown(e)) return false;
964      const c = focusables(e)[0] || (e.hasAttribute('tabindex') ? e : null);
965      return !!c && take(c);
966    };
967    for (let e = after; e; e = e.nextElementSibling) if (tryRow(e)) return;
968    for (let e = before; e; e = e.previousElementSibling) if (tryRow(e)) return;
969    document.getElementById('main')?.focus({ preventScroll: true });
970  };
971  const setState = (s) => { const el = document.querySelector('.hint-live'); if (el) el.dataset.state = s; };
972  // fitIn lays out the one-line chip lists of a fragment now in the page.
973  const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
974  return {
975    // teardown runs remove, which deletes the stream element. hx-sse has no
976    // public close API: its cleanup aborts the fetch, then calls
977    // connection.reader.cancel() without catching the AbortError it rejects
978    // with. Wrapping that one reader handles exactly this rejection, with no
979    // timing window and no global filter.
980    async teardown(remove) {
981      const reader = connection?.reader;
982      if (reader) connection.reader = { cancel: () => reader.cancel().catch(() => {}) };
983      return remove();
984    },
985    resumeStream(element) {
986      // The public connection includes id-only messages as well as named ones.
987      const id = connection?.lastEventId;
988      if (!id) return;
989      const url = new URL(element.getAttribute('hx-sse:connect'), location.href);
990      url.searchParams.set('after', id);
991      element.setAttribute('hx-sse:connect', url.pathname + url.search);
992    },
993    // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
994    // only if the stream is still down LOST_AFTER later.
995    lost() {
996      if (lostTimer) return;
997      lostTimer = setTimeout(() => { lostTimer = 0; setState('offline'); }, LOST_AFTER);
998    },
999    // back is htmx:sse:after:connection.
1000    back() {
1001      clearTimeout(lostTimer);
1002      lostTimer = 0;
1003      setState('live');
1004    },
1005    swap(html) {
1006      const t = document.createElement('template');
1007      t.innerHTML = html;
1008      const cursor = document.querySelector('[data-rows] > [data-cursor]');
1009      const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href');
1010      for (const el of [...t.content.children]) {
1011        // Each fresh fragment owns its timer; an older swap cannot expire it.
1012        // No animation, including for visitors who prefer reduced motion.
1013        if (el.id.startsWith('repo-') && el.hasAttribute('data-activity')) {
1014          setTimeout(() => {
1015            el.querySelector('.repo-activity')?.remove();
1016            el.removeAttribute('data-activity');
1017          }, 60000);
1018        }
1019        const old = el.id && document.getElementById(el.id);
1020        const had = old ? where(old) : null; // the focus is inside what is replaced
1021        if (el.hasAttribute('data-gone')) {
1022          const after = old?.nextElementSibling, before = old?.previousElementSibling;
1023          old?.remove();
1024          if (had) lostFocus(after, before);
1025          continue;
1026        }
1027        if (!old) {
1028          const rows = document.getElementById('repo-rows');
1029          if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); fitIn(el); }
1030          continue;
1031        }
1032        if (old.hasAttribute('data-slot')) {
1033          old.replaceChildren(...el.childNodes);
1034          if (had) refocus(old, had);
1035          continue;
1036        }
1037        if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
1038        // data-keep-checks: the checkboxes the user ticked stay ticked.
1039        if (old.hasAttribute('data-keep-checks')) {
1040          for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
1041            const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
1042            if (same) same.checked = true;
1043          }
1044        }
1045        old.replaceWith(el);
1046        const rows = document.getElementById('repo-rows');
1047        if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
1048            rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el);
1049        window.htmx?.process(el);
1050        fitIn(el);
1051        if (had) refocus(el, had);
1052      }
1053      // The rows may have changed under an active filter: apply it again and
1054      // keep the cursor on its row while that row is still shown.
1055      for (const input of document.querySelectorAll('input[data-filter]')) {
1056        if (input.value) window.ermite?.hide(input);
1057      }
1058      if (!cursorKey) return;
1059      const rows = [...document.querySelectorAll('[data-rows] > *')];
1060      const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey));
1061      if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back);
1062    },
1063    reload() {
1064      if (reloading) return;
1065      reloading = true;
1066      setTimeout(() => {
1067        reloading = false;
1068        if (window.ermiteLive.canReload?.() !== false) location.reload();
1069      }, 300);
1070    },
1071    // error handles htmx:sse:error (detail.status is the status of a refused
1072    // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
1073    // itself, and the fetched page says what is the matter (or has no stream).
1074    // A page that reloaded for this a moment ago and still gets refused would
1075    // loop: it stops following the stream instead (see gate).
1076    error(status) {
1077      if (status !== 404 && status !== 401) return;
1078      let last = 0;
1079      try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ }
1080      if (Date.now() - last < 15000) { stopped = true; return; }
1081      try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ }
1082      this.reload();
1083    },
1084    // gate is htmx:sse:before:connection: once stopped, no more attempts.
1085    gate(event) {
1086      if (stopped) event.preventDefault();
1087    },
1088  };
1089})();

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 4

Tags 45

↑↓ moveenter openesc close