1// Blame and history on a file page (site/repo/blob.njk), from the file's2// blame.json (lib/blame.ts), fetched the first time either is asked for.3// Blame puts a column before each line: the commit that last changed it, on4// the first line of each run, and when. Clicking it opens the commit (lines5// are links to themselves, so the column can't hold a link of its own; the6// history panel has the same commits as links). History lists the commits7// that changed the file, newest first, with the review each landed in.8(() => {9 const viewer = document.querySelector<HTMLElement>('[data-blame-src]');10 const code = viewer?.querySelector<HTMLElement>('.code.blob');11 const blameBtn = document.querySelector<HTMLButtonElement>('button[data-blame]');12 const historyBtn = document.querySelector<HTMLButtonElement>('button[data-history]');13 const panel = document.querySelector<HTMLElement>('#history');14 if (!viewer || !code || !blameBtn || !historyBtn || !panel) return;15 const src = viewer.dataset.blameSrc!;16 const commitBase = viewer.dataset.commitBase!;17 const reviewBase = viewer.dataset.reviewBase!;1819 // [sha, subject, author, time (s), review, review title, has a page]20 type Row = [string, string, string, number, number | null, string | null, number];21 interface Data {22 commits: Row[];23 ranges: [number, number, number][];24 history: number[];25 more?: boolean;26 }27 let data: Promise<Data> | null = null;28 const load = (): Promise<Data> => (data ??= fetch(src).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))));2930 // Compact, as times in a column are elsewhere (ui.ts); the full time on hover.31 const full = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });32 const day = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });33 const compact = (t: Date): string => {34 const s = (Date.now() - t.getTime()) / 1000;35 if (s < 60) return 'now';36 if (s < 3600) return `${Math.floor(s / 60)}m`;37 if (s < 86400) return `${Math.floor(s / 3600)}h`;38 if (s < 7 * 86400) return `${Math.floor(s / 86400)}d`;39 if (s < 28 * 86400) return `${Math.floor(s / (7 * 86400))}w`;40 if (t.getFullYear() === new Date().getFullYear()) return day.format(t);41 return t.toISOString().slice(0, 10);42 };43 const time = (secs: number): HTMLTimeElement => {44 const t = new Date(secs * 1000);45 const el = document.createElement('time');46 el.dateTime = t.toISOString();47 el.title = full.format(t);48 el.textContent = compact(t);49 return el;50 };51 const href = (c: Row): string | null => (c[6] ? `${commitBase}${c[0]}/` : null);5253 const failed = (btn: HTMLButtonElement): void => {54 btn.disabled = true;55 btn.title = 'blame.json could not be loaded';56 };5758 let drawn = false;59 function drawBlame(d: Data): void {60 if (drawn) return;61 drawn = true;62 const lines = code!.querySelectorAll<HTMLElement>('.l');63 d.ranges.forEach(([start, n, ci], band) => {64 const c = d.commits[ci];65 for (let k = 0; k < n; k++) {66 const l = lines[start - 1 + k];67 if (!l) continue;68 const s = document.createElement('span');69 s.className = `bl${band % 2 ? ' alt' : ''}${k === 0 ? ' first' : ''}`;70 s.dataset.c = String(ci);71 // The line is a link to itself; its name stays the line. History has the same facts as links.72 s.setAttribute('aria-hidden', 'true');73 s.title = `${c[0].slice(0, 7)} ${c[1]}, ${c[2]}${c[4] != null ? `, review #${c[4]}` : ''}`;74 if (k === 0) {75 s.append(`${c[0].slice(0, 7)} `, time(c[3]));76 }77 l.prepend(s);78 }79 });80 code!.addEventListener('click', (e) => {81 const s = (e.target as HTMLElement).closest<HTMLElement>('.bl');82 if (!s || !code!.classList.contains('blamed')) return;83 const c = d.commits[Number(s.dataset.c)];84 const to = c && href(c);85 if (!to) return;86 e.preventDefault();87 location.href = to;88 });89 }9091 function drawHistory(d: Data): void {92 const body = panel!.querySelector('tbody')!;93 if (body.childElementCount) return;94 for (const ci of d.history) {95 const c = d.commits[ci];96 const tr = document.createElement('tr');97 const when = document.createElement('td');98 when.className = 'date';99 when.appendChild(time(c[3]));100 const subj = document.createElement('td');101 subj.className = 'main';102 const to = href(c);103 const a = document.createElement(to ? 'a' : 'span');104 if (to) (a as HTMLAnchorElement).href = to;105 a.textContent = c[1];106 subj.appendChild(a);107 const who = document.createElement('td');108 who.className = 'hide-sm';109 who.textContent = c[2];110 const rv = document.createElement('td');111 rv.className = 'rv';112 if (c[4] != null) {113 const r = document.createElement('a');114 r.href = `${reviewBase}${c[4]}/`;115 r.textContent = `#${c[4]}`;116 r.title = c[5] ?? '';117 rv.appendChild(r);118 }119 const sha = document.createElement('td');120 sha.className = 'sha hide-sm';121 sha.textContent = c[0].slice(0, 7);122 tr.append(when, subj, who, rv, sha);123 body.appendChild(tr);124 }125 const grow = panel!.querySelector('.panel-head .grow');126 if (grow) grow.textContent = `${d.history.length}${d.more ? '+' : ''} ${d.history.length === 1 ? 'commit' : 'commits'}, newest first${d.more ? '; older ones are in the log' : ''}`;127 }128129 const KEY = 'forge.blame';130 const setBlame = (on: boolean): void => {131 blameBtn.setAttribute('aria-pressed', String(on));132 if (!on) {133 code.classList.remove('blamed');134 localStorage.removeItem(KEY);135 return;136 }137 localStorage.setItem(KEY, '1');138 load().then((d) => {139 drawBlame(d);140 code.classList.add('blamed');141 }, () => failed(blameBtn));142 };143 blameBtn.addEventListener('click', () => setBlame(blameBtn.getAttribute('aria-pressed') !== 'true'));144 historyBtn.addEventListener('click', () => {145 const on = historyBtn.getAttribute('aria-pressed') !== 'true';146 historyBtn.setAttribute('aria-pressed', String(on));147 if (!on) {148 panel.hidden = true;149 return;150 }151 load().then((d) => {152 drawHistory(d);153 panel.hidden = false;154 }, () => failed(historyBtn));155 });156 // Blame stays on from page to page, like wrap lines.157 if (localStorage.getItem(KEY) === '1') setBlame(true);158})();