Skip to content

Instantly share code, notes, and snippets.

@stewartadam
Last active September 4, 2026 00:57
Show Gist options
  • Select an option

  • Save stewartadam/14c9c5bf7fbb60b7264f918897527f9f to your computer and use it in GitHub Desktop.

Select an option

Save stewartadam/14c9c5bf7fbb60b7264f918897527f9f to your computer and use it in GitHub Desktop.
Add shortcuts for navigating GH PR diffs via Tampermonkey

GitHub PR Review Shortcuts

A Tampermonkey userscript for keyboard-first navigation through GitHub pull-request diffs.

Shortcuts

  • j: move to the next viewport-sized diff segment
  • k: move to the previous viewport-sized diff segment
  • v: toggle the selected file's Viewed state and advance to the next file
  • l: load the selected large diff when GitHub defers it

Clicking within a file diff selects it. The selected diff is marked with a red line in its left gutter. Shortcuts are disabled while typing in an editable control or holding a modifier key.

Install

  1. Install and enable Tampermonkey, including Chrome's Allow User Scripts extension setting.
  2. Open the raw userscript.
  3. Confirm the Tampermonkey installation prompt.
  4. Reload a GitHub pull request's Files changed page.

Tampermonkey checks the Gist's raw userscript URL for updates according to its configured update interval.

Test

npm install
npx playwright install chromium
npm test

The Playwright suite uses controlled GitHub-style fixtures and does not modify a real pull request. Its configuration fixes the viewport size so tall-diff segment boundaries remain deterministic.

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { expect, type Route, test } from "@playwright/test";
const userscript = readFileSync(
fileURLToPath(
new URL("./github-pr-review-shortcuts.user.js", import.meta.url),
),
"utf8",
);
/** Returns representative signed-in GitHub changed-file markup for shortcut tests. */
function githubFilesMarkup(): string {
return `
<style>
body { margin: 0; }
.sticky-toolbar { background: white; height: 60px; left: 0; position: fixed; right: 0; top: 0; z-index: 10; }
.page-intro { height: 120px; }
.diff-viewport { margin-left: 16px; overflow: hidden; }
.js-file { height: 700px; margin-bottom: 24px; }
.file-header { height: 48px; }
</style>
<div class="sticky-toolbar"></div>
<input aria-label="Unrelated page input" />
<div class="page-intro"></div>
<div class="diff-viewport">
<div id="file-1" class="file js-file" data-details-container-group="file">
<div class="file-header" data-path="src/first.rs">
<input class="js-reviewed-toggle" type="checkbox" aria-label="Viewed" />
</div>
</div>
<div id="file-2" class="file js-file" data-details-container-group="file">
<div class="file-header" data-path="src/second.rs">
<label class="js-reviewed-toggle">
<input type="checkbox" /> Viewed
</label>
</div>
</div>
<div id="file-3" class="file js-file" data-details-container-group="file">
<div class="file-header" data-path="src/third.rs">
<input class="js-reviewed-checkbox" type="checkbox" />
</div>
</div>
</div>
`;
}
/** Returns GitHub's virtualized large-PR changed-file markup. */
function githubVirtualizedFilesMarkup(): string {
return `
<style>
body { margin: 0; }
.page-intro { height: 120px; }
[role="region"] { height: 700px; margin-bottom: 24px; }
</style>
<div class="page-intro"></div>
<div id="diff-first" role="region" aria-labelledby="heading-first" data-estimated-height="700">
<div data-diff-header-wrapper="true">
<h3 id="heading-first">src/first.rs</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed" onclick="this.setAttribute('aria-pressed', 'true'); this.setAttribute('aria-label', 'Viewed')">Viewed</button>
</div>
</div>
<div id="diff-second" role="region" aria-labelledby="heading-second" data-estimated-height="700">
<div data-diff-header-wrapper="true">
<h3 id="heading-second">src/second.rs</h3>
<button type="button" aria-pressed="true" aria-label="Viewed" onclick="this.setAttribute('aria-pressed', 'false'); this.setAttribute('aria-label', 'Not Viewed')">Viewed</button>
</div>
</div>
<div id="diff-third" role="region" aria-labelledby="heading-third" data-estimated-height="700">
<div data-diff-header-wrapper="true">
<h3 id="heading-third">src/third.rs</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed" onclick="this.setAttribute('aria-pressed', 'true'); this.setAttribute('aria-label', 'Viewed')">Viewed</button>
</div>
</div>
`;
}
/** Returns a virtualized diff whose first file spans multiple viewport segments. */
function githubTallFileMarkup(): string {
return `
<style>
body { margin: 0; }
.page-intro { height: 120px; }
[role="region"] { margin-bottom: 24px; }
#diff-tall { height: 1727px; }
#diff-next { height: 700px; }
</style>
<div class="page-intro"></div>
<div id="diff-tall" role="region" aria-labelledby="heading-tall">
<h3 id="heading-tall">.vscode/launch.json</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed" onclick="this.setAttribute('aria-pressed', 'true'); this.setAttribute('aria-label', 'Viewed')">Viewed</button>
</div>
<div id="diff-next" role="region" aria-labelledby="heading-next">
<h3 id="heading-next">src/next.rs</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed" onclick="this.setAttribute('aria-pressed', 'true'); this.setAttribute('aria-label', 'Viewed')">Viewed</button>
</div>
`;
}
/** Returns a virtual list that mounts its next file only after scrolling. */
function githubDeferredVirtualFileMarkup(): string {
return `
<style>
body { margin: 0; }
.page-intro { height: 120px; }
#virtual-list { height: 4000px; position: relative; }
.virtual-row { left: 0; position: absolute; right: 0; }
[role="region"] { height: 700px; }
</style>
<div class="page-intro"></div>
<div id="virtual-list">
<div class="virtual-row" data-index="0" data-path-digest="tall" style="top: 0">
<div id="diff-tall" role="region" aria-labelledby="heading-tall">
<h3 id="heading-tall">.vscode/launch.json</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed">Viewed</button>
</div>
</div>
</div>
<template id="next-file-template">
<div class="virtual-row" data-index="1" data-path-digest="next" style="top: 724px">
<div id="diff-next" role="region" aria-labelledby="heading-next">
<h3 id="heading-next">.vscode/settings.json</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed">Viewed</button>
</div>
</div>
</template>
<script>
/** Mounts the next simulated virtual row after navigation enters unloaded space. */
function mountNextVirtualFile() {
const template = document.querySelector("#next-file-template");
if (window.scrollY < 500 || !template) return;
document.querySelector("#virtual-list").append(template.content.cloneNode(true));
template.remove();
}
window.addEventListener("scroll", mountNextVirtualFile, { passive: true });
</script>
`;
}
/** Returns GitHub's progressive diff markup before the next header is hydrated. */
function githubProgressiveDiffMarkup(): string {
return `
<style>
body { margin: 0; }
.page-intro { height: 120px; }
[role="region"] { height: 700px; margin-bottom: 24px; }
</style>
<div class="page-intro"></div>
<div id="diff-current" role="region" aria-labelledby="heading-current">
<h3 id="heading-current">scripts/report-browser-demo-size.mjs</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed" onclick="this.setAttribute('aria-pressed', 'true'); this.setAttribute('aria-label', 'Viewed'); hydrateNextDiff()">Viewed</button>
</div>
<div id="diff-next" role="region">
<div id="diff-next" role="region" aria-label="Loading scripts/run-playwright-vite.mjs"></div>
</div>
<div id="diff-third" role="region" aria-labelledby="heading-third">
<h3 id="heading-third">scripts/run-playwright.mjs</h3>
</div>
<script>
/** Simulates GitHub hydrating a progressive diff after its preceding file is viewed. */
function hydrateNextDiff() {
requestAnimationFrame(() => {
const nextDiff = document.querySelector("#diff-next");
nextDiff.setAttribute("aria-labelledby", "heading-next");
nextDiff.removeAttribute("aria-label");
nextDiff.innerHTML = '<h3 id="heading-next">scripts/run-playwright-vite.mjs</h3>';
});
}
</script>
`;
}
/** Returns a file whose generated diff is deferred behind GitHub's load control. */
function githubDeferredLargeDiffMarkup(): string {
return `
<style>
body { margin: 0; }
.page-intro { height: 120px; }
[role="region"] { height: 700px; }
</style>
<div class="page-intro"></div>
<div id="diff-generated" role="region" aria-labelledby="heading-generated">
<h3 id="heading-generated">Cargo.lock</h3>
<button type="button" aria-pressed="false" aria-label="Not Viewed">Viewed</button>
<button type="button" data-loading="false" onclick="loadDeferredDiff(this)">Load Diff</button>
</div>
<script>
/** Simulates GitHub replacing a diff region after its loading spinner appears. */
function loadDeferredDiff(button) {
button.dataset.loading = "true";
button.textContent = "Loading";
requestAnimationFrame(() => {
const currentDiff = document.querySelector("#diff-generated");
const loadedDiff = document.createElement("div");
loadedDiff.id = "diff-generated";
loadedDiff.setAttribute("role", "region");
loadedDiff.setAttribute("aria-labelledby", "heading-generated-loaded");
loadedDiff.innerHTML = '<h3 id="heading-generated-loaded">Cargo.lock</h3><div data-loaded="true">Loaded</div>';
currentDiff.replaceWith(loadedDiff);
});
}
</script>
`;
}
/** Loads a PR files route with a controlled GitHub diff fixture and the userscript. */
async function loadUserscriptFixture(
page: import("@playwright/test").Page,
markup = githubFilesMarkup(),
beforeUserscript = "",
) {
/** Serves the controlled diff fixture without contacting GitHub. */
async function fulfillGitHubRoute(route: Route) {
await route.fulfill({
body: markup,
contentType: "text/html",
});
}
const fixtureUrl = "https://github.com/owner/repository/pull/123/files";
await page.route(fixtureUrl, fulfillGitHubRoute);
await page.goto(fixtureUrl);
if (beforeUserscript) await page.addScriptTag({ content: beforeUserscript });
await page.addScriptTag({ content: userscript });
}
/** Returns the changed file currently highlighted by the userscript. */
function activeFile(page: import("@playwright/test").Page) {
return page.locator('[data-github-pr-shortcuts-active="true"]');
}
/** Verifies j and k move through rendered changed-file segments without wrapping. */
test("navigates forward and backward between changed files", async ({
page,
}) => {
await loadUserscriptFixture(page);
await expect(activeFile(page)).toHaveCount(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-1");
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-2");
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-3");
await page.keyboard.press("k");
await expect(activeFile(page)).toHaveId("file-2");
await page.keyboard.press("k");
await expect(activeFile(page)).toHaveId("file-1");
await page.keyboard.press("k");
await expect(activeFile(page)).toHaveId("file-1");
});
/** Verifies ordinary navigation includes files already marked viewed. */
test("navigates through viewed files after startup", async ({ page }) => {
const markup = githubFilesMarkup().replace(
'<input type="checkbox" /> Viewed',
'<input type="checkbox" checked /> Viewed',
);
await loadUserscriptFixture(page, markup);
await expect(activeFile(page)).toHaveCount(0);
expect(await page.evaluate<number>("window.scrollY")).toBe(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-1");
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-2");
await expect
.poll(() =>
activeFile(page).evaluate((file) => file.getBoundingClientRect().top),
)
.toBe(68);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-3");
});
/** Verifies collapsed viewed files remain visible below GitHub's sticky toolbar. */
test("keeps a selected collapsed file below the sticky toolbar", async ({
page,
}) => {
const markup = `${githubFilesMarkup().replace(
".js-file { height: 700px;",
".js-file { height: 42px;",
)}<div style="height: 1000px"></div>`;
await loadUserscriptFixture(page, markup);
await page.locator("#file-1").click({ position: { x: 100, y: 20 } });
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-2");
await expect
.poll(() =>
activeFile(page).evaluate((file) => file.getBoundingClientRect().top),
)
.toBe(68);
});
/** Verifies installing the userscript leaves initial selection and scrolling untouched. */
test("does not select or scroll to a file on startup", async ({ page }) => {
await loadUserscriptFixture(page);
await expect(activeFile(page)).toHaveCount(0);
expect(await page.evaluate<number>("window.scrollY")).toBe(0);
});
/** Verifies v marks an unviewed file and advances to the following changed file. */
test("marks the current file viewed and advances", async ({ page }) => {
await loadUserscriptFixture(page);
await page.keyboard.press("v");
await expect(page.locator("#file-1 .js-reviewed-toggle")).toBeChecked();
await expect(activeFile(page)).toHaveId("file-2");
});
/** Verifies v unmarks a viewed file and still advances to the next file. */
test("unmarks an already viewed file and advances", async ({ page }) => {
await loadUserscriptFixture(page);
await page.locator("#file-1 .js-reviewed-toggle").check();
await page.keyboard.press("v");
await expect(page.locator("#file-1 .js-reviewed-toggle")).not.toBeChecked();
await expect(activeFile(page)).toHaveId("file-2");
});
/** Verifies nested and legacy GitHub viewed controls remain usable. */
test("supports nested and legacy viewed controls", async ({ page }) => {
await loadUserscriptFixture(page);
await page.keyboard.press("j");
await page.keyboard.press("j");
await page.keyboard.press("v");
await expect(page.locator("#file-2 input[type='checkbox']")).toBeChecked();
await expect(activeFile(page)).toHaveId("file-3");
await page.keyboard.press("v");
await expect(page.locator("#file-3 .js-reviewed-checkbox")).toBeChecked();
await expect(activeFile(page)).toHaveId("file-3");
});
/** Verifies GitHub's virtualized large-PR regions and pressed buttons work. */
test("supports the virtualized large pull request viewer", async ({ page }) => {
await loadUserscriptFixture(page, githubVirtualizedFilesMarkup());
await expect(activeFile(page)).toHaveCount(0);
await page.keyboard.press("v");
await expect(page.locator("#diff-first button")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.locator("#diff-second button")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(activeFile(page)).toHaveId("diff-second");
await page.keyboard.press("v");
await expect(page.locator("#diff-second button")).toHaveAttribute(
"aria-pressed",
"false",
);
await expect(activeFile(page)).toHaveId("diff-third");
});
/** Verifies j and k traverse viewport-sized segments before changing files. */
test("navigates within a changed file taller than the viewport", async ({
page,
}) => {
await loadUserscriptFixture(page, githubTallFileMarkup());
await expect(activeFile(page)).toHaveCount(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-tall");
const fileTop = await page.evaluate<number>("window.scrollY");
await page.keyboard.press("j");
const nextSegment = await page.evaluate<number>("window.scrollY");
expect(nextSegment).toBeGreaterThan(fileTop + 500);
await expect(activeFile(page)).toHaveId("diff-tall");
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-next");
await page.keyboard.press("k");
await expect(activeFile(page)).toHaveId("diff-tall");
const previousSegment = await page.evaluate<number>("window.scrollY");
expect(previousSegment).toBeGreaterThan(fileTop + 500);
await page.keyboard.press("k");
expect(await page.evaluate<number>("window.scrollY")).toBeLessThan(
previousSegment - 500,
);
await expect(activeFile(page)).toHaveId("diff-tall");
});
/** Verifies v skips remaining viewport segments and advances to the next file. */
test("marking a tall file viewed advances to the next diff block", async ({
page,
}) => {
await loadUserscriptFixture(page, githubTallFileMarkup());
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-tall");
await page.keyboard.press("v");
await expect(page.locator("#diff-tall button")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(activeFile(page)).toHaveId("diff-next");
});
/** Verifies navigation wakes GitHub's virtualizer when no next file is mounted. */
test("reveals an unmounted next file in the virtualized viewer", async ({
page,
}) => {
await loadUserscriptFixture(page, githubDeferredVirtualFileMarkup());
await expect(activeFile(page)).toHaveCount(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-tall");
await expect(page.locator("#diff-next")).toHaveCount(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-next");
expect(await page.evaluate<number>("window.scrollY")).toBeGreaterThan(500);
});
/** Verifies v can select GitHub's next progressive diff before it is hydrated. */
test("keeps the next progressive diff highlighted while it loads", async ({
page,
}) => {
await loadUserscriptFixture(page, githubProgressiveDiffMarkup());
await page.keyboard.press("v");
await expect(page.locator("#diff-current button")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(activeFile(page)).toHaveId("diff-next");
await expect(page.locator("#diff-next")).toHaveAttribute(
"aria-labelledby",
"heading-next",
);
await expect(activeFile(page)).toHaveId("diff-next");
});
/** Verifies nested loading regions are not mistaken for additional changed files. */
test("skips nested progressive diff placeholders during navigation", async ({
page,
}) => {
await loadUserscriptFixture(page, githubProgressiveDiffMarkup());
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-current");
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-next");
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-third");
});
/** Verifies l activates GitHub's deferred generated-file diff control. */
test("loads the active file's deferred large diff", async ({ page }) => {
await loadUserscriptFixture(page, githubDeferredLargeDiffMarkup());
await expect(activeFile(page)).toHaveCount(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("diff-generated");
await page.keyboard.press("l");
await expect(page.locator("#diff-generated [data-loaded='true']")).toHaveText(
"Loaded",
);
await expect(activeFile(page)).toHaveId("diff-generated");
});
/** Verifies GitHub cannot pre-empt the userscript by default-preventing letter keys. */
test("captures shortcuts before GitHub's key handler", async ({ page }) => {
await loadUserscriptFixture(
page,
githubFilesMarkup(),
'/** Simulates GitHub claiming a keyboard event first. */ function preventShortcut(event) { event.preventDefault(); } window.addEventListener("keydown", preventShortcut, { capture: true });',
);
await expect(activeFile(page)).toHaveCount(0);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveId("file-1");
});
/** Verifies the page advertises the injected userscript version for diagnosis. */
test("marks the document when the userscript is running", async ({ page }) => {
await loadUserscriptFixture(page);
await expect(page.locator("html")).toHaveAttribute(
"data-github-pr-review-shortcuts",
"1.0.13",
);
await expect(activeFile(page)).toHaveCount(0);
});
/** Verifies letter shortcuts remain available while typing or using modifiers. */
test("ignores editable targets and modified shortcuts", async ({ page }) => {
await loadUserscriptFixture(page);
await page.getByRole("textbox", { name: "Unrelated page input" }).focus();
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveCount(0);
await page.getByRole("textbox", { name: "Unrelated page input" }).blur();
await page.keyboard.press("Control+j");
await expect(activeFile(page)).toHaveCount(0);
});
/** Verifies pressing a changed-file body selects it without requiring a shortcut. */
test("selects a changed file by clicking its body", async ({ page }) => {
await loadUserscriptFixture(page);
await page.locator("#file-2").click({ position: { x: 100, y: 100 } });
await expect(activeFile(page)).toHaveId("file-2");
});
/** Verifies the current file has a visible red marker in its left gutter. */
test("visually marks the active changed file gutter", async ({
page,
}, testInfo) => {
await loadUserscriptFixture(page);
await page.keyboard.press("j");
await expect(activeFile(page)).toHaveCSS("position", "relative");
await expect
.poll(() =>
activeFile(page).evaluate(
(file) => getComputedStyle(file, "::before").backgroundColor,
),
)
.toBe("rgb(248, 81, 73)");
await expect
.poll(() =>
activeFile(page).evaluate(
(file) => getComputedStyle(file, "::before").width,
),
)
.toBe("4px");
await expect
.poll(() =>
activeFile(page).evaluate(
(file) => getComputedStyle(file, "::before").zIndex,
),
)
.toBe("2147483647");
await page.evaluate(() => {
const opaqueGitHubSurface = document.createElement("div");
opaqueGitHubSurface.style.cssText =
"position:absolute;inset:0;background:white;z-index:1";
document.querySelector("#file-1")?.append(opaqueGitHubSurface);
});
await activeFile(page).screenshot({
path: testInfo.outputPath("github-pr-review-shortcuts-active-file.png"),
});
});
// ==UserScript==
// @name GitHub PR Review Shortcuts
// @namespace https://github.com/
// @version 1.0.13
// @description Navigate and select PR diffs, load deferred diffs, and toggle files viewed.
// @match https://github.com/*/*/pull/*
// @downloadURL https://gist.githubusercontent.com/stewartadam/14c9c5bf7fbb60b7264f918897527f9f/raw/github-pr-review-shortcuts.user.js
// @updateURL https://gist.githubusercontent.com/stewartadam/14c9c5bf7fbb60b7264f918897527f9f/raw/github-pr-review-shortcuts.user.js
// @supportURL https://gist.github.com/stewartadam/14c9c5bf7fbb60b7264f918897527f9f
// @grant none
// @run-at document-idle
// ==/UserScript==
/**
* Adds keyboard-first changed-file navigation to GitHub pull request diff pages.
*
* Shortcuts:
* - j: jump to the next viewport-sized diff segment
* - k: jump to the previous viewport-sized diff segment
* - l: load the current file's deferred large diff
* - v: toggle the current file's viewed state and jump to the next file
*/
(function initializeGitHubPrReviewShortcuts() {
const ACTIVE_ATTRIBUTE = "data-github-pr-shortcuts-active";
const FILE_SELECTOR = [
'.js-file[data-details-container-group="file"]',
'[role="region"][id^="diff-"]',
].join(",");
const SCRIPT_ATTRIBUTE = "data-github-pr-review-shortcuts";
const SCRIPT_VERSION = "1.0.13";
const VIEWED_CONTROL_SELECTOR = [
".js-reviewed-toggle",
".js-reviewed-checkbox",
'input[type="checkbox"][aria-label="Viewed"]',
'input[type="checkbox"][aria-label="Mark as viewed"]',
'button[aria-pressed][aria-label="Not Viewed"]',
'button[aria-pressed][aria-label="Viewed"]',
].join(",");
const MAX_VIRTUAL_RENDER_FRAMES = 120;
const SEGMENT_OVERLAP_PX = 96;
const VIEWPORT_GAP_PX = 8;
let activeFile = null;
let activeFileIdentity = null;
let navigationRevision = 0;
/** Returns whether the current URL is a pull request's changed-files view. */
function isPullRequestFilesPage() {
return /^\/[^/]+\/[^/]+\/pull\/\d+\/(?:files|changes)\/?$/.test(
window.location.pathname,
);
}
/** Returns the changed-file containers currently rendered by GitHub. */
function getFiles() {
return Array.from(document.querySelectorAll(FILE_SELECTOR)).filter(
(file) => !file.parentElement?.closest(FILE_SELECTOR),
);
}
/** Returns the first unobscured vertical position below fixed or sticky chrome. */
function getViewportAnchor() {
const probeX = Math.max(
0,
Math.min(window.innerWidth - 1, window.innerWidth / 2),
);
const overlays = document.elementsFromPoint(probeX, 1);
const coveringBottom = overlays.reduce((bottom, element) => {
if (element.closest(FILE_SELECTOR)) return bottom;
const position = getComputedStyle(element).position;
if (position !== "fixed" && position !== "sticky") return bottom;
const bounds = element.getBoundingClientRect();
if (bounds.top > 1 || bounds.bottom <= 1) return bottom;
return Math.max(bottom, bounds.bottom);
}, 0);
return coveringBottom + VIEWPORT_GAP_PX;
}
/** Returns whether an event target is an editor where letter keys should type normally. */
function isEditableTarget(target) {
if (!(target instanceof Element)) return false;
const editor = target.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]',
);
if (!(editor instanceof HTMLInputElement)) return Boolean(editor);
return ![
"button",
"checkbox",
"color",
"file",
"radio",
"range",
"reset",
"submit",
].includes(editor.type);
}
/** Removes the active marker before selecting a different changed file. */
function clearActiveFile() {
navigationRevision += 1;
activeFile?.removeAttribute(ACTIVE_ATTRIBUTE);
activeFile = null;
activeFileIdentity = null;
}
/** Marks a file as active without changing the viewport position. */
function activateFile(file) {
clearActiveFile();
activeFile = file;
activeFileIdentity = {
id: file.id || null,
virtualIndex: getVirtualFileIndex(file),
};
activeFile.setAttribute(ACTIVE_ATTRIBUTE, "true");
}
/** Restores selection when GitHub replaces a diff while loading its content. */
function restoreActiveFile() {
if (!activeFileIdentity) return;
if (activeFile?.isConnected) {
if (activeFile.getAttribute(ACTIVE_ATTRIBUTE) !== "true") {
activeFile.setAttribute(ACTIVE_ATTRIBUTE, "true");
}
return;
}
const replacement = getFiles().find((file) => {
if (activeFileIdentity.id) return file.id === activeFileIdentity.id;
return (
activeFileIdentity.virtualIndex !== null &&
getVirtualFileIndex(file) === activeFileIdentity.virtualIndex
);
});
if (!replacement) return;
activeFile = replacement;
activeFile.style.setProperty(
"--github-pr-shortcuts-scroll-margin-top",
`${getViewportAnchor()}px`,
);
activeFile.setAttribute(ACTIVE_ATTRIBUTE, "true");
}
/** Marks a file as active and immediately scrolls its header into view. */
function focusFile(file) {
activateFile(file);
activeFile.style.setProperty(
"--github-pr-shortcuts-scroll-margin-top",
`${getViewportAnchor()}px`,
);
activeFile.scrollIntoView({ behavior: "auto", block: "start" });
}
/** Focuses the edge of an adjacent file that continues navigation naturally. */
function focusAdjacentFile(file, direction) {
if (
direction > 0 ||
file.getBoundingClientRect().height <= window.innerHeight
) {
focusFile(file);
return;
}
activateFile(file);
activeFile.scrollIntoView({ behavior: "auto", block: "end" });
}
/** Returns the file containing the viewport anchor, or the next file below it. */
function getViewportFile(files) {
const viewportAnchor = getViewportAnchor();
for (const file of files) {
const bounds = file.getBoundingClientRect();
if (bounds.top <= viewportAnchor && bounds.bottom > viewportAnchor) {
return file;
}
}
for (const file of files) {
if (file.getBoundingClientRect().top > viewportAnchor) return file;
}
return null;
}
/** Returns the active file, falling back to the file at the viewport anchor. */
function getCurrentFile(files) {
if (activeFile?.isConnected && files.includes(activeFile))
return activeFile;
return getViewportFile(files);
}
/** Returns GitHub's virtual-list index for a rendered file, when present. */
function getVirtualFileIndex(file) {
const row = file.closest("[data-index][data-path-digest]");
const index = Number(row?.getAttribute("data-index"));
return Number.isInteger(index) ? index : null;
}
/** Finds a rendered file by its stable index in GitHub's virtualized list. */
function getVirtualFile(index) {
return getFiles().find((file) => getVirtualFileIndex(file) === index);
}
/** Waits briefly for GitHub to render a requested virtualized file. */
function focusVirtualFileWhenRendered(
targetIndex,
direction,
revision,
focusRenderedFile,
remainingFrames = MAX_VIRTUAL_RENDER_FRAMES,
) {
if (revision !== navigationRevision) return;
const targetFile = getVirtualFile(targetIndex);
if (targetFile) {
focusRenderedFile(targetFile, direction);
return;
}
if (remainingFrames > 0) {
requestAnimationFrame(() =>
focusVirtualFileWhenRendered(
targetIndex,
direction,
revision,
focusRenderedFile,
remainingFrames - 1,
),
);
}
}
/** Scrolls into unloaded list space so GitHub renders an adjacent file. */
function revealVirtualizedAdjacentFile(
file,
direction,
focusRenderedFile = focusAdjacentFile,
) {
const row = file.closest("[data-index][data-path-digest]");
const virtualList = row?.parentElement;
const currentIndex = getVirtualFileIndex(file);
if (!row || !virtualList || currentIndex === null) return false;
const fileBounds = file.getBoundingClientRect();
const listBounds = virtualList.getBoundingClientRect();
const hasUnloadedSpace =
direction > 0
? listBounds.bottom > fileBounds.bottom + SEGMENT_OVERLAP_PX
: listBounds.top < fileBounds.top - SEGMENT_OVERLAP_PX;
if (!hasUnloadedSpace) return false;
const revision = ++navigationRevision;
window.scrollBy({
behavior: "auto",
top: direction * Math.max(1, window.innerHeight - SEGMENT_OVERLAP_PX),
});
focusVirtualFileWhenRendered(
currentIndex + direction,
direction,
revision,
focusRenderedFile,
);
return true;
}
/** Focuses the immediately adjacent file, mounting its virtual row if needed. */
function focusAdjacentChangedFile(files, currentFile, direction) {
const virtualIndex = getVirtualFileIndex(currentFile);
if (virtualIndex !== null) {
const adjacentFile = getVirtualFile(virtualIndex + direction);
if (adjacentFile) {
focusAdjacentFile(adjacentFile, direction);
return true;
}
return revealVirtualizedAdjacentFile(currentFile, direction);
}
const currentIndex = files.indexOf(currentFile);
const adjacentFile = files[currentIndex + direction];
if (!adjacentFile) return false;
focusAdjacentFile(adjacentFile, direction);
return true;
}
/** Scrolls one viewport-sized segment within a tall file when content remains. */
function scrollWithinFile(file, direction) {
const bounds = file.getBoundingClientRect();
const segmentSize = Math.max(1, window.innerHeight - SEGMENT_OVERLAP_PX);
const viewportAnchor = getViewportAnchor();
if (
direction > 0 &&
bounds.bottom > window.innerHeight + SEGMENT_OVERLAP_PX
) {
window.scrollBy({
behavior: "auto",
top: Math.min(
segmentSize,
bounds.bottom - window.innerHeight + SEGMENT_OVERLAP_PX,
),
});
return true;
}
if (direction < 0 && bounds.top < viewportAnchor - SEGMENT_OVERLAP_PX) {
window.scrollBy({
behavior: "auto",
top: -Math.min(segmentSize, viewportAnchor - bounds.top),
});
return true;
}
return false;
}
/** Navigates one viewport-sized diff segment without wrapping. */
function navigateFiles(direction) {
const files = getFiles();
if (files.length === 0) return;
const currentFile = getCurrentFile(files);
if (!currentFile) {
if (direction > 0) focusFile(files[0]);
return;
}
const currentBounds = currentFile.getBoundingClientRect();
if (
!activeFile &&
direction > 0 &&
currentBounds.top > getViewportAnchor()
) {
focusFile(currentFile);
return;
}
if (!activeFile) activateFile(currentFile);
if (scrollWithinFile(currentFile, direction)) return;
focusAdjacentChangedFile(files, currentFile, direction);
}
/** Clicks GitHub's deferred large-diff control in the current file. */
function loadCurrentFileDiff() {
const currentFile = getCurrentFile(getFiles());
const loadButton = Array.from(
currentFile?.querySelectorAll("button") ?? [],
).find((button) => button.textContent?.trim() === "Load Diff");
loadButton?.click();
}
/** Toggles the current file's viewed state, then advances to its next file. */
function toggleCurrentFileViewed() {
const files = getFiles();
const currentFile = getCurrentFile(files);
if (!currentFile) return;
const viewedControl = currentFile.querySelector(VIEWED_CONTROL_SELECTOR);
if (!viewedControl) return;
viewedControl.click();
focusAdjacentChangedFile(files, currentFile, 1);
}
/** Selects the changed file whose body received a pointer press. */
function handlePointerDown(event) {
if (!(event.target instanceof Element)) return;
const file = event.target.closest(FILE_SELECTOR);
if (file) activateFile(file);
}
/** Handles supported unmodified shortcuts while preserving GitHub and editor input. */
function handleKeyDown(event) {
if (
!isPullRequestFilesPage() ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
isEditableTarget(event.target)
) {
return;
}
const key = event.key.toLowerCase();
if (key === "j") {
event.preventDefault();
event.stopPropagation();
navigateFiles(1);
} else if (key === "k") {
event.preventDefault();
event.stopPropagation();
navigateFiles(-1);
} else if (key === "l" && !event.repeat) {
event.preventDefault();
event.stopPropagation();
loadCurrentFileDiff();
} else if (key === "v" && !event.repeat) {
event.preventDefault();
event.stopPropagation();
toggleCurrentFileViewed();
}
}
/** Installs a red gutter marker for the file selected by keyboard or pointer. */
function installActiveFileStyle() {
const style = document.createElement("style");
style.textContent = `
[${ACTIVE_ATTRIBUTE}="true"] {
position: relative !important;
scroll-margin-top: var(--github-pr-shortcuts-scroll-margin-top, ${VIEWPORT_GAP_PX}px);
}
[${ACTIVE_ATTRIBUTE}="true"]::before {
background: var(--fgColor-danger, var(--color-danger-fg, #f85149));
content: "";
inset: 0 auto 0 0;
pointer-events: none;
position: absolute;
width: 4px;
z-index: 2147483647;
}
`;
document.head.append(style);
}
document.documentElement.setAttribute(SCRIPT_ATTRIBUTE, SCRIPT_VERSION);
installActiveFileStyle();
new MutationObserver(restoreActiveFile).observe(document.documentElement, {
attributeFilter: [ACTIVE_ATTRIBUTE],
attributes: true,
childList: true,
subtree: true,
});
window.addEventListener("keydown", handleKeyDown, { capture: true });
document.addEventListener("pointerdown", handlePointerDown, {
passive: true,
});
})();
{
"name": "github-pr-review-shortcuts",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test github-pr-review-shortcuts.spec.ts"
},
"devDependencies": {
"@playwright/test": "^1.56.1"
}
}
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
viewport: { height: 900, width: 1366 },
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment