1// Keyboard navigation. Pages work without it; this only adds shortcuts.2// j/k move through table rows, Enter/o opens3// n/N next/previous commit or file in a diff4// t or p find a file on this branch5// ? the list of shortcuts below6// links with data-key: header letters (l f b r w s e i h g), u goes up,7// [ and ] for stack layers. diffs.ts adds x and v for diff files.8(() => {9 // Every shortcut, for the help overlay.10 const KEYS: [string[], string][] = [11 [['?'], 'this help'],12 [['/'], 'search'],13 [['t', 'p'], 'find a file'],14 [['j', 'k'], 'next / previous row'],15 [['Enter', 'o'], 'open the selected row'],16 [['n', 'N'], 'next / previous commit or file'],17 [['[', ']'], 'previous / next layer of a stack'],18 [['x'], 'fold or unfold the file in view'],19 [['v'], 'mark the file in view as viewed'],20 [['h'], 'repository home'],21 [['f', 'l', 'b', 'r'], 'code, commits, branches, reviews'],22 [['w', 's', 'e', 'i'], 'wiki, sites, releases, insights'],23 [['u'], 'up a level'],24 [['g'], 'all repositories'],25 ];2627 // Table rows, the repo cards on the index (filtered ones are hidden), and28 // list items such as reviews.29 const rows = (): HTMLElement[] =>30 [...document.querySelectorAll<HTMLElement>('table.nav tbody tr, .nav-cards > li, .nav-list > li')].filter((el) => el.querySelector('a') && el.getClientRects().length);31 let at = -1;3233 function select(i: number): void {34 const list = rows();35 if (!list.length) return;36 at = Math.max(0, Math.min(list.length - 1, i));37 list.forEach((r) => r.classList.remove('sel'));38 list[at].classList.add('sel');39 list[at].scrollIntoView({ block: 'nearest' });40 }4142 function jump(dir: 1 | -1): void {43 const marks = [...document.querySelectorAll<HTMLElement>('.stacked.jump')];44 const list = marks.length ? marks : [...document.querySelectorAll<HTMLElement>('.jump')];45 const y = window.scrollY + 4;46 // Where scrollIntoView puts it: scroll-margin keeps it clear of sticky bars.47 const top = (e: HTMLElement): number => e.getBoundingClientRect().top + window.scrollY - (parseFloat(getComputedStyle(e).scrollMarginTop) || 0);48 const target = dir > 0 ? list.find((e) => top(e) > y + 4) : [...list].reverse().find((e) => top(e) < y - 4);49 if (target) {50 target.scrollIntoView();51 history.replaceState(null, '', '#' + target.id);52 }53 }5455 interface Match {56 total: number;57 hits: number[];58 }5960 // Subsequence match with a score that prefers basename and contiguous hits.61 function score(query: string, path: string): Match | null {62 const q = query.toLowerCase();63 const s = path.toLowerCase();64 let qi = 0;65 let last = -2;66 let total = 0;67 const hits: number[] = [];68 const base = s.lastIndexOf('/') + 1;69 for (let i = 0; i < s.length && qi < q.length; i++) {70 if (s[i] !== q[qi]) continue;71 total += (i === last + 1 ? 3 : 1) + (i >= base ? 2 : 0);72 hits.push(i);73 last = i;74 qi++;75 }76 return qi === q.length ? { total: total - s.length * 0.01, hits } : null;77 }7879 let files: string[] | null = null;80 async function finder(): Promise<void> {81 const src = document.body.dataset.files;82 if (!src || document.getElementById('finder')) return;83 files ??= (await (await fetch(src)).text()).split('\n').filter(Boolean);84 const all = files;85 const box = document.createElement('div');86 box.id = 'finder';87 box.innerHTML = '<input placeholder="find file" autocomplete="off" spellcheck="false"><ol></ol>';88 document.body.append(box);89 const input = box.querySelector('input')!;90 const ol = box.querySelector('ol')!;91 let sel = 0;92 let shown: { p: string; s: Match }[] = [];93 const base = document.body.dataset.blob ?? '';94 const href = (p: string): string => base + p.split('/').map(encodeURIComponent).join('/') + '/';95 const draw = (): void => {96 const q = input.value.trim();97 const scored = q98 ? all.map((p) => ({ p, s: score(q, p) })).filter((x): x is { p: string; s: Match } => !!x.s).sort((a, b) => b.s.total - a.s.total)99 : all.map((p) => ({ p, s: { total: 0, hits: [] } }));100 shown = scored.slice(0, 30);101 sel = Math.min(sel, Math.max(0, shown.length - 1));102 ol.replaceChildren(...shown.map((x, i) => {103 const li = document.createElement('li');104 const a = document.createElement('a');105 a.href = href(x.p);106 const hits = new Set(x.s.hits);107 for (let j = 0; j < x.p.length; j++) {108 if (hits.has(j)) {109 const b = document.createElement('b');110 b.textContent = x.p[j];111 a.append(b);112 } else a.append(x.p[j]);113 }114 li.append(a);115 if (i === sel) li.classList.add('sel');116 return li;117 }));118 };119 const close = (): void => box.remove();120 input.addEventListener('input', () => {121 sel = 0;122 draw();123 });124 input.addEventListener('keydown', (e) => {125 if (e.key === 'Escape') close();126 else if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {127 sel = Math.min(sel + 1, shown.length - 1);128 draw();129 } else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) {130 sel = Math.max(sel - 1, 0);131 draw();132 } else if (e.key === 'Enter' && shown[sel]) location.href = href(shown[sel].p);133 else return;134 e.preventDefault();135 });136 input.addEventListener('blur', () => setTimeout(close, 150));137 draw();138 input.focus();139 }140141 // A modal <dialog>: Escape closes it natively; so do ? and a click outside.142 function help(): void {143 let d = document.getElementById('help') as HTMLDialogElement | null;144 if (!d) {145 const box = document.createElement('dialog');146 box.id = 'help';147 box.setAttribute('aria-labelledby', 'help-title');148 const rows = KEYS.map(([keys, what]) => `<tr><td>${keys.map((k) => `<kbd>${k}</kbd>`).join(' ')}</td><td>${what}</td></tr>`);149 box.innerHTML = `<div><h2 id="help-title">keyboard shortcuts</h2><table>${rows.join('')}</table><p class="desc">Esc closes. Shortcuts that don't apply to a page do nothing there.</p></div>`;150 box.addEventListener('click', (e) => e.target === box && box.close());151 document.body.append(box);152 d = box;153 }154 if (d.open) d.close();155 else d.showModal();156 }157158 document.querySelector('footer.site .keyhelp')?.addEventListener('click', help);159160 document.addEventListener('keydown', (e) => {161 if ((e.target as Element | null)?.closest('input, textarea, select') || e.metaKey || e.ctrlKey || e.altKey) return;162 const k = e.key;163 if (document.querySelector('dialog[open]') && k !== '?') return;164 if (k === 'j') select(at + 1);165 else if (k === 'k') select(at - 1);166 else if ((k === 'Enter' || k === 'o') && at >= 0) {167 const a = rows()[at]?.querySelector('a');168 if (!a) return;169 location.href = a.href;170 } else if (k === 'n') jump(1);171 else if (k === 'N') jump(-1);172 else if (k === 't' || k === 'p') void finder();173 else if (k === '?') help();174 else {175 const a = document.querySelector<HTMLAnchorElement>(`a[data-key="${CSS.escape(k)}"]`);176 if (!a) return;177 location.href = a.href;178 }179 e.preventDefault();180 });181})();