Skip to content

Instantly share code, notes, and snippets.

@gibson042
Last active June 18, 2026 04:28
Show Gist options
  • Select an option

  • Save gibson042/d8ab425d133400959bc5bb875898a97b to your computer and use it in GitHub Desktop.

Select an option

Save gibson042/d8ab425d133400959bc5bb875898a97b to your computer and use it in GitHub Desktop.
GitHub Fixer user script
// ==UserScript==
// @name GitHub Fixer
// @namespace https://github.com/gibson042
// @description Make headers and pull request tabs sticky, expand all toggles on <{Alt,Option}+Shift+E>.
// @source https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b
// @updateURL https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b/raw/github-fixer.user.js
// @downloadURL https://gist.github.com/gibson042/d8ab425d133400959bc5bb875898a97b/raw/github-fixer.user.js
// @version 0.14.3
// @date 2026-06-18
// @author Richard Gibson <@gmail.com>
// @include https://github.com/*
// ==/UserScript==
//
// **COPYRIGHT NOTICE**
//
// To the extent possible under law, the author(s) have dedicated all copyright
// and related and neighboring rights to this software to the public domain
// worldwide. This software is distributed without any warranty.
// For the CC0 Public Domain Dedication, see
// <https://creativecommons.org/publicdomain/zero/1.0/>.
//
// **END COPYRIGHT NOTICE**
//
//
// Changelog:
// 0.14.3 (2026-06-18)
// * Improved: Make copy-file-path button icons render without font support.
// 0.14.2 (2026-06-13)
// * Improved: Auto-expand collapsed comments and low-line-count diff context.
// 0.14.1 (2026-06-10)
// * Improved: Better location/scroll restoration after expand all toggles.
// 0.14.0 (2026-06-10)
// * Fixed: Restored and improved "expand all toggles" functionality.
// 0.13.6 (2026-06-04)
// * Fixed: Reserve sufficient space for the repository menu in the Issue view.
// 0.13.5 (2026-06-02)
// * Fixed: Better identify file headers that need copy-file-path buttons.
// 0.13.4 (2026-06-01)
// * New: Make /pull/$n Conversation review comment file headers sticky.
// 0.13.2 (2026-05-31)
// * Improved: Tweak styles of the invisible floating-repository-menu container.
// 0.13.1 (2026-05-30)
// * Fixed: After-the-fact copy-file-path button addition
// 0.13.0 (2026-05-29)
// * New: Make /compare/$branchName Compare View file headers sticky.
// 0.12.3 (2026-05-28)
// * New: Add copy-file-path buttons to search result file links.
// 0.12.2 (2026-05-27)
// * Fixed: Prevent overlap of flex-shrunk items in the PR Files Changed header.
// 0.12.1 (2026-05-21)
// * New: Add copy-file-path buttons to PR review pages.
// 0.11.13 (2026-05-18)
// * Improved: Float the repository menu on every page.
// 0.11.7 (2026-05-15)
// * Fixed: Restore shrinkability to "merge $n commits into $targetBranch from $sourceBranch".
// 0.11.6 (2026-05-15)
// * Fixed: Reserve sufficient space for the repository menu in the new PR view.
// 0.11.5 (2026-05-14)
// * Fixed: Account for the notifications banner.
// 0.11.4 (2026-05-14)
// * Improved: Sticky headers for file view/edit pages as well.
// 0.11.3 (2026-05-14)
// * Improved: Make the Actions workflow run header sticky.
// 0.11.1 (2026-05-13)
// * Fixed: Reconstitute pull request titles that ellipsis-split a single commit's subject.
// 0.11.0 (2026-05-13)
// * New: Make the Actions job run header sticky.
// 0.10.2 (2026-05-12)
// * Fixed: Restore repository-menu stickiness.
// 0.9.0 (2026-05-11)
// * Fixed: Don't hide GitHub's sticky header.
// 0.8.0 (2025-12-16)
// * Updated: Remove now-redundant CI check filter toggles.
// 0.7.2 (2025-10-02)
// * Fixed: Support the new pull request "Files Changed" experience preview.
// 0.7.0 (2025-09-10)
// * Fixed: Work with new is-stuck behavior from toggle-stuck.ts.
// 0.6.0 (2025-01-02)
// * New: <{Alt,Option}+Shift+E> expands all comments/files/etc.
// 0.5.0 (2024-12-30)
// * Fixed: Support "the new merge experience".
// 0.4.6 (2024-12-12)
// * Improved: Use better styles for the repo navigation menu when it is stuck.
// 0.4.0 (2024-12-04)
// * New: Add the small-screen repo navigation menu to the sticky header for mid-page access.
// 0.3.2 (2024-12-03)
// * Improved: Better align colors with the GitHub theme and user color scheme preferences.
// 0.3.0 (2024-10-16)
// * New: Reconstitute pull request titles that ellipsis-split a single commit's subject.
// 0.2.1 (2024-09-10)
// * Improved: Make CI check status filter controls more accessible.
// 0.2.0 (2024-09-10)
// * Fixed: Manually differentiate "cancelled" vs. "action required" CI checks.
// 0.1.3 (2024-04-18)
// * Improved: Support "action required" CI checks (and note inability to support "cancelled" vs. "failing").
// 0.1.2 (2024-03-12)
// * Fixed: Support GitHub's dynamic content replacement.
// 0.1.1 (2024-03-11)
// * Fixed: Differentiate "queued" vs. "expected" CI checks.
// 0.1.0 (2024-03-08)
// * New: Support CI check filtering by status.
// 0.0.1 (2023-09-08)
// * original release
(function fix() {
"use strict";
const ID = "gibson042-github-fixer";
const log = console.log.bind(console, `[${ID}]`);
const warn = console.warn.bind(console, `[${ID}]`);
const error = console.error.bind(console, `[${ID}]`);
const BUTTON_SELECTOR = "button, input[type=button], [role=button]";
const VISUALLY_HIDDEN_CSS_PROPERTIES = `
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
`.replace(/\s+/, " ");
const { defineProperty, entries: toEntries, freeze } = Object;
const { apply } = Reflect;
const dataProperty = freeze({ configurable: true, enumerable: false, writable: true });
const defineName = (name, fn) => defineProperty(fn, "name", { __proto__: null, value: name });
const withResolvers = () => {
let resolve, reject;
const promise = new Promise((...args) => ({ 0: resolve, 1: reject } = args));
return { promise, resolve, reject };
};
const sink = () => {};
const applyInfallible = (fn, receiver, args) =>
(async () => apply(fn, receiver, args))().catch(sink);
const isVisible = node => {
const elDeep = node.nodeType === 1 ? node : node.parentNode;
for (let el = elDeep; el?.nodeType === 1; el = el.parentNode) {
const style = getComputedStyle(el);
if (style.display === "none" || el === elDeep && style.visibility === "hidden") return false;
}
return true;
};
const addPassiveListener = (node, type, fn, options) =>
node.addEventListener(type, fn, { __proto__: null, passive: true, ...options });
// Reconstitute pull request titles that ellipsis-split a single commit's subject.
const elPRTitle = document.querySelector("#new_pull_request input:is(#pull_request_title, [name='pull_request[title]'])");
if (elPRTitle?.value.endsWith("…")) {
const elPRBody = document.querySelector("#new_pull_request #pull_request_body");
const mTail = /^…(.*)\n*/.exec(elPRBody?.value);
if (mTail) {
elPRTitle.value = elPRTitle.value.slice(0, -1) + mTail[1];
elPRBody.value = elPRBody.value.slice(mTail[0].length);
}
}
// /pull/$n/changes Files changed "classic experience"
// * PR tabs: DIV.tabnav > NAV.tabnav-tabs > A.tabnav-tab
// * sticky header:
// DIV.pr-toolbar.js-toggle-stuck.js-observe-sticky-header-height
// * gains/loses class "is-stuck"
// * PR status chip: :scope > DIV > DIV.show-if-stuck > DIV#pull-state
// * strategy: Make DIV.tabnav become sticky with the header, invisibly
// floating over it at top left (i.e., over the status chip).
//
// /pull/$n/changes Files changed experience preview 2025-Q4
// * PR tabs:
// HEADER.prc-PageLayout-Header-0of-R
// DIV.prc-PageHeader-PageHeader-YLwBQ >
// DIV.prc-PageHeader-Navigation--uLav[data-component=PH_Navigation]
// NAV >
// DIV[role=tablist] >
// A[role=tab]
// * DIV.prc-PageHeader-PageHeader-YLwBQ has display:grid with tabs in a
// bottom section
// * sticky header:
// HEADER.prc-PageLayout-Header-0of-R +
// DIV.prc-PageLayout-PageLayoutContent-BneH9 SECTION.use-sticky-header-module__stickyHeader__sf0hv.PullRequestFilesToolbar-module__toolbar__ztHN6
// * gains/loses class "PullRequestFilesToolbar-module__is-stuck__jSrl_"
// * PR status chip:
// :scope
// DIV.PullRequestFilesToolbar-module__show-when-stuck__UjE3N >
// SPAN[data-status]
// * strategy: Make HEADER.prc-PageLayout-Header-0of-R become sticky with
// the header and absolutely position the tablist-containing NAV to
// invisibly float over the status chip.
//
// /pull/$n Conversation and /pull/$n/commits Commits
// * PR tabs: same as Files changed experience preview
// * sticky header: like Files changed experience preview, but inside the header.
// HEADER.prc-PageLayout-Header-0of-R >
// DIV.prc-PageLayout-HeaderContent-gdFfN >
// DIV.prc-PageHeader-PageHeader-YLwBQ.use-sticky-header-module__stickyHeader__sf0hv
// * gains/loses class "StickyPullRequestHeader-module__is-stuck__BQKQx"
// * PR status chip: :scope SPAN[data-status]
// * strategy: similar to the classic strategy, make the tablist-containing
// NAV become sticky with the header, invisibly floating over it at top
// left (i.e., over the status chip).
document.head.insertAdjacentHTML("beforeend", String.raw`<style type="text/css" class="${ID}">
/* Restore shrinkability to the new-interface "merge $n commit(s) into $targetBranch from $sourceBranch" component. */
header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck'] [class*=PullRequestHeaderSummary i] > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Expose sticky repository links. */
.position-relative:has(nav[aria-label=Repository]), :root:not(:has(.position-relative nav[aria-label=Repository])) *:has(> nav[aria-label=Repository]) {
container-name: ${ID}-repo-links-parent;
container-type: scroll-state;
position: sticky !important;
top: 0 !important;
z-index: 1000;
pointer-events: none;
> * { pointer-events: auto; }
}
@container ${ID}-repo-links-parent scroll-state(stuck: top) {
*:not(nav[aria-label=Repository], nav[aria-label=Repository] *):is(#force#high#specificity, *) {
visibility: hidden;
pointer-events: none;
}
nav[aria-label=Repository] {
visibility: visible;
pointer-events: auto;
position: fixed;
top: 5px;
right: 0;
max-width: 90px;
overflow: hidden;
padding-left: 0;
padding-right: 10px;
box-shadow: none;
z-index: 999;
&:is(:hover, :active, :focus, :focus-within, :has(:active)) {
overflow: visible;
}
[aria-expanded] {
border: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, var(--color-border-muted, #d1d9e0b3));
&:not(:hover) {
background: var(--button-default-bgColor-rest, var(--color-btn-bg, var(--overlay-bgColor, white)));
}
/* Partially hide on hover (in case we're obscuring something). */
&:not([aria-expanded=true]):hover {
opacity: 0.45;
transition: opacity 3s cubic-bezier(1, 0, 1, 0);
}
}
[aria-expanded] [data-component=buttonContent] [data-component=text] * { ${VISUALLY_HIDDEN_CSS_PROPERTIES} }
[aria-expanded] [data-component=buttonContent] [data-component=text]::after {
content: '\1F181'; /* NEGATIVE SQUARED LATIN CAPITAL LETTER R */
}
/* tooltip */
*:has(> [aria-expanded])::after {
position: absolute;
top: -1px;
right: 0;
margin-right: 90%;
background: var(--tooltip-bgColor,#25292e);
border-radius: var(--borderRadius-medium,.375rem);
padding: var(--overlay-paddingBlock-condensed,.25rem) var(--overlay-padding-condensed,.5rem);
color: var(--tooltip-fgColor,#fff);
font: var(--text-body-shorthand-small,400 .75rem/1.625 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif);
content: 'Repository Menu';
opacity: 0;
visibility: hidden;
transition: opacity 100ms 250ms;
}
:has(> [aria-expanded]:hover)::after {
opacity: 1;
visibility: visible;
}
[data-component=ActionList] {
left: unset !important;
right: 0;
width: max-content;
}
/* Restore spacing for counter chips. */
[data-component=counter] {
margin-inline-start: var(--base-size-8, .5rem);
}
}
}
/* Reserve space */
.notification-shelf {
border-right: 30px solid var(--bgColor-inset, transparent) !important;
}
header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck'] {
border-right: 50px solid transparent;
}
#issue-viewer-sticky-header {
border-right: 25px solid transparent;
}
div[class*='PageLayout-SidebarWrapper']:has(#docked-side-panel-content),
[class*=Panel-module]:has(#symbols-pane) {
container-name: side-panel;
container-type: scroll-state;
}
@container side-panel scroll-state(stuck: top) {
button:has(.octicon-x) {
margin-right: 40px;
}
}
:root:has(
header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck'],
div[class*='PageLayout-SidebarWrapper'] #docked-side-panel-content
) nav[aria-label=Repository] [aria-expanded] {
opacity: unset;
animation: none;
}
/* Expose repository links over a sticky header (old interface). */
button[data-action^='click:deferred-side-panel'].is-stuck {
top: 12px !important;
left: 12px !important;
background-color: var(--${ID}-bgColor);
z-index: 1;
}
:root:has(button[data-action^='click:deferred-side-panel'].is-stuck) [role=region][aria-labelledby] > h2:first-child {
margin-left: min(var(--pane-width, 0px), 40px);
}
.AppHeader .AppHeader-localBar.is-stuck {
position: fixed;
left: auto !important;
right: 12px !important;
max-width: min-content;
padding-left: 0;
z-index: 9999;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav {
box-shadow: none;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-body {
display: none;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-actions {
visibility: visible !important;
padding-right: 0 !important;
}
.AppHeader .AppHeader-localBar.is-stuck .UnderlineNav-actions [popovertarget]:not([popover] *) {
zoom: 0.8;
}
.gh-header-sticky.is-stuck, [data-testid=issue-metadata-sticky] /*, [I WISH!] :stuck */ {
padding-right: 16px;
}
/* Place a sticky tabnav over the sticky header top left, invisible except on hover. */
:root, body {
--${ID}-bgColor: var(--overlay-bgColor, #ffffff);
--${ID}-borderColor: var(--overlay-borderColor, #d1d9e080);
--${ID}-highlight-bgColor: var(--highlight-neutral-bgColor, #fff8c5);
}
@media (prefers-color-scheme: dark) {
:root, body {
--${ID}-bgColor: var(--overlay-bgColor, #151b23);
--${ID}-borderColor: var(--overlay-borderColor, #3d444db3);
--${ID}-highlight-bgColor: var(--highlight-neutral-bgColor, #d2992266);
}
}
/* .tabnav predates the 2025-Q4 "Files Changed" experience preview. */
.tabnav.js-toggle-stuck {
width: unset !important;
}
.tabnav.is-stuck {
position: sticky;
max-width: 100px;
}
/*
* header[class*='PageLayout-Header' i] is in the "Files Changed"
* experience preview and "Conversation"/"Commits", but the
* "Files Changed" sticky bar descends from its next sibling while the
* "Conversation"/"Commits" sticky bar descends from it directly.
*/
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader]) {
/* A minimum size for the overlayed PR status chip. */
--${ID}-status-chip-width: 100px;
}
header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck'] [data-status] {
min-width: var(--${ID}-status-chip-width, 100px);
justify-content: space-between;
}
header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck'] [data-status]::after {
content: '';
flex-basis: 16px;
flex-shrink: 1;
}
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] :has(> [role=tablist]) {
position: fixed;
top: 0;
/* avoid covering the file tree button [data-testid="expand-file-tree-button"] */
--${ID}-left-pad: 32px;
margin-left: var(--${ID}-left-pad);
--${ID}-width: var(--${ID}-status-chip-width, 100px);
min-width: var(--${ID}-width);
max-width: var(--${ID}-width);
}
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] :has(> [role=tablist]):hover {
margin-left: 0;
--${ID}-width: calc(var(--${ID}-left-pad) + var(--${ID}-status-chip-width, 100px));
}
header[class*='PageLayout-Header' i]:has([class*='is-stuck']) :has(> [role=tablist]) {
position: fixed;
top: 0;
min-width: 90px;
max-width: 90px;
}
/* Our styling for the all page navigation tablists is the same. */
.tabnav.is-stuck,
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] :has(> [role=tablist]),
header[class*='PageLayout-Header' i]:has([class*='is-stuck']) :has(> [role=tablist]) {
max-height: 60px;
padding-top: 60px;
overflow: auto;
z-index: 999;
opacity: 0;
:root:has(.notification-shelf) & {
margin-top: 68px;
}
}
.tabnav.is-stuck:hover,
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] :has(> [role=tablist]):hover,
header[class*='PageLayout-Header' i]:has([class*='is-stuck']) :has(> [role=tablist]):hover {
max-width: fit-content;
max-height: unset;
overflow: unset;
opacity: 1;
}
/* Hide children except the navigation tabs, and arrange those vertically. */
.tabnav.is-stuck > * {
display: none !important;
}
.tabnav.is-stuck > nav.tabnav-tabs,
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] [role=tablist],
header[class*='PageLayout-Header' i]:has([class*='is-stuck']) [role=tablist] {
display: flex !important;
flex-direction: column;
background-color: var(--${ID}-bgColor);
border: 1px solid var(--${ID}-borderColor);
}
/* Update tab styling for better vertical functionality. */
.tabnav.is-stuck *:where(.tabnav-tab.selected, .tabnav-tab[aria-selected=true], .tabnav-tab[aria-current]:not([aria-current=false])),
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] [role=tablist] [role=tab][aria-selected=true],
header[class*='PageLayout-Header' i]:has([class*='is-stuck']) [role=tablist] [role=tab][aria-selected=true] {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.tabnav.is-stuck .tabnav-tab:hover,
:root:has(header[class*='PageLayout-Header' i] + * section[class*=stickyHeader][class*='is-stuck']) header[class*='PageLayout-Header' i] [role=tablist] [role=tab]:hover,
header[class*='PageLayout-Header' i]:has([class*='is-stuck']) [role=tablist] [role=tab]:hover {
background-color: var(--${ID}-highlight-bgColor);
}
/*
* Also make sticky headers for:
* - /compare/$branchName file headers
* - /pull/$n Conversation review comment file headers
* - /actions/runs/$x?pr=$n Actions workflow run
* - /actions/runs/$x/job/$y?pr=$n Actions job run
* - /blob/path/to/file viewer
* - /edit/path/to/file editor
*/
.files-bucket .file-header {
position: sticky;
top: 0;
}
review-thread-collapsible.open .js-toggle-outdated-comments,
review-thread-collapsible.open :has(> [data-target="review-thread-collapsible.button"]),
review-thread-collapsible.open:not(:has(.js-toggle-outdated-comments, [data-target="review-thread-collapsible.button"])) > *:first-child {
position: sticky;
top: calc(var(--base-size-60, 53px) + 2 * var(--base-size-8, 0.5rem) + var(--borderWidth-thin, 0.0625rem));
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-bottom: var(--borderWidth-thin, 1px) solid var(--borderColor-default, var(--color-border-default, #d1d9e0));
z-index: 1;
}
:root:has(section[aria-label*="run summary"], div[aria-label*="run summary"]):has(.PageLayout-header--hasDivider),
:root:has(h1 + [class*=BlobEditor], h1 + :not([class*=BlobEditor]) + [class*=BlobEditor]) {
&:has(section[aria-label*="run summary"], div[aria-label*="run summary"]) .PageLayout-header--hasDivider,
h1 + [class*=BlobEditor], h1 + :not([class*=BlobEditor]) + [class*=BlobEditor] {
position: sticky;
top: 0;
/*
max-height: 90px;
overflow: hidden;
*/
background: var(--bgColor-default, var(--color-canvas-default, white));
border-bottom: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, var(--color-border-muted, #d1d9e0b3)) !important;
z-index: 999;
&[class*=BlobEditor] { padding-right: 50px; }
}
&:has(section[aria-label*="run summary"], div[aria-label*="run summary"]) {
:is([data-target="split-page-layout.pane"], .CheckRun-header) {
top: 90px;
}
.CheckStep-header:is([style*="top:88px"], [style*="top:"][style*=px]) {
top: calc(90px + 88px) !important;
}
}
h1 + [class*=BlobEditor], h1 + :not([class*=BlobEditor]) + [class*=BlobEditor] {
div:has(> * > &) {
padding-top: 8px !important;
}
padding-top: 8px;
padding-bottom: 8px;
~ * [class*=BlobEditHeader] {
position: sticky;
top: 49px;
z-index: 999;
}
~ * .cm-panels-bottom:is(#force#high#specificity, *) {
bottom: 0px !important;
}
}
}
/* Style manual file-path copy buttons */
clipboard-copy.${ID}:has(> button) {
margin-right: var(--base-size-8, 8px) !important;
}
clipboard-copy.${ID} > button {
width: 2.5em;
border: var(--borderWidth-thin, .0625rem) solid;
border-radius: var(--borderRadius-medium, .375rem);
border-color: var(--button-invisible-borderColor-rest, #fff0);
background-color: #0000;
color: var(--button-invisible-iconColor-rest, #59636e);
font-family: inherit;
font-size: var(--text-body-size-small, .75rem);
font-weight: bold;
user-select: none;
& > svg {
display: inline-block;
height: 1em;
width: 1em;
vertical-align: text-top;
overflow: visible;
fill: currentColor;
}
&.color-fg-success > svg {
display: none;
}
&.color-fg-success::after {
content: '\2714'; /* HEAVY CHECK MARK */
}
&:hover {
background-color: var(--button-invisible-bgColor-hover, #818b981a);
border-color: var(--button-invisible-borderColor-hover, #fff0);
}
*:has(> clipboard-copy > &) {
align-items: baseline !important;
}
}
/* Style our notifier */
.${ID}-running {
position: fixed;
right: 0;
bottom: 0;
border: 1px solid white;
background: darkorange;
color: white;
font-weight: bold;
display: flex;
align-items: baseline;
column-gap: 1ex;
@media not (prefers-reduced-motion) { transition: opacity 2s; }
&.done { opacity: 0; }
& > :first-child { margin-inline-start: 1ex; }
}
</style>`);
// Ensure stickiness on resize/scroll.
{
let tDebounce = -Infinity;
let waiting = false;
const ensureStickyNav = evt => {
const t = evt.timeStamp;
if (t < tDebounce) {
waiting ||= setTimeout(ensureStickyNav, 100, { timeStamp: t + 100 });
return;
}
tDebounce = t + 100;
waiting = false;
const selGlobalNav = ":root:not(:has(.AppHeader .AppHeader-localBar)) button[data-action^='click:deferred-side-panel']";
const selRepoNav = ".AppHeader .AppHeader-localBar";
const selPageNav = ".js-pull-header-details ~ .tabnav";
const stickyClass = "js-toggle-stuck";
const newStickyNavs = document.querySelectorAll(
[selGlobalNav, selRepoNav, selPageNav].map(s => `${s}:not(.${stickyClass})`).join(", "),
);
for (const el of newStickyNavs) el.classList.add(stickyClass, "top-0");
// Because elRepoNav.is-stuck is position:fixed, delegate its unsticking to a sentinel element.
const elRepoNav = newStickyNavs.length && document.querySelector(selRepoNav);
if (elRepoNav) {
if (!elRepoNav.id) elRepoNav.id = `${ID}-repo-nav`;
const id = elRepoNav.id;
const stickyTargetsAttr = "data-toggle-sticky-element";
const elStickyHeaderSentinel = document.querySelector(`[${stickyTargetsAttr}]`);
if (elStickyHeaderSentinel) {
const stickyIds = elStickyHeaderSentinel.getAttribute(stickyTargetsAttr);
if (!stickyIds.split(",").includes(id)) {
elStickyHeaderSentinel.setAttribute(stickyTargetsAttr, stickyIds.replace(/.$/, "$&,") + id);
}
} else {
const newSentinel = createElement("div", { [stickyTargetsAttr]: id, class: "js-toggle-stuck" });
elRepoNav.insertAdjacentElement("afterend", newSentinel);
}
}
// Unhide elRepoNav menu items.
const navMenuItems = document.querySelectorAll(
`${selRepoNav}.is-stuck .UnderlineNav-actions [role=menu] > [hidden]`,
);
for (const el of navMenuItems) el.hidden = false;
// If anything was made sticky, dispatch a synthetic scroll in case it should already be stuck.
if (newStickyNavs.length && !waiting) document.dispatchEvent(new Event("scroll"));
};
for (const capture of [true, false]) {
addPassiveListener(window, "resize", ensureStickyNav, { capture });
addPassiveListener(document, "scroll", ensureStickyNav, { capture });
}
const loadedKit = withResolvers();
loadedKit.promise.then(ensureStickyNav);
window.addEventListener("load", loadedKit.resolve, { once: true });
if (document.readyState === "complete") loadedKit.resolve({ timeStamp: 0 });
}
// Add copy-file-path buttons to PR review pages
if (location.pathname.match(/[/]pull[/][0-9]+$|^[/]search$/)) {
const copyConfirmationMsDelay = 2000;
const copyClickedClass = "color-fg-success";
const elCopyIcon = createElement(
["http://www.w3.org/2000/svg", "svg"],
{ "aria-hidden": true, viewBox: "0 0 16 16" },
[
"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z",
"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z",
].map(d => createElement(["http://www.w3.org/2000/svg", "path"], { d })),
);
const copyTimeouts = new WeakMap();
const onCopyButtonClick = evt => {
const elButton = evt.currentTarget;
const oldTimeout = copyTimeouts.get(elButton);
if (oldTimeout) clearTimeout(oldTimeout);
elButton.classList.add(copyClickedClass);
const timeout = setTimeout(() => { elButton.classList.remove(copyClickedClass); }, copyConfirmationMsDelay);
copyTimeouts.set(elButton, timeout);
};
let stopWatching;
const addFilePathCopyButtons = () => {
const bareFileLinks = document.querySelectorAll([
`review-thread-collapsible > :first-child:not(:has(.octicon-copy, clipboard-copy.${ID})) a[href]`,
`[data-testid="results-list"] .search-title:not(:has(.octicon-copy, clipboard-copy.${ID})) a[data-testid="link-to-search-result"]`,
].join(", "));
for (const elFileLink of bareFileLinks) {
const elButton = createElement(
"button",
{ type: "button", tabindex: -1, title: "Copy file path" },
[elCopyIcon.cloneNode(true)],
);
const elButtonContainer = createElement(
"clipboard-copy",
{ class: ID, tabindex: 0, value: elFileLink.textContent },
[elButton],
);
// As of 2026-05, search result links on LTR pages are RTL (for overflow behavior).
const elParent = elFileLink.parentNode;
const elBefore = getComputedStyle(elParent).direction === getComputedStyle(elParent.parentNode).direction
? elFileLink
: elParent;
elBefore.after(elButtonContainer);
addPassiveListener(elButton, "click", onCopyButtonClick);
}
stopWatching = forEachMutation(document.body, { childList: true, subtree: true }, (_mutation, i) => {
// Ignore the initial-state invocation.
if (i === undefined) return;
// Otherwise, schedule the scan and abandon this watcher.
stopWatching?.();
scheduleAddFilePathCopyButtons();
return true;
});
};
const scheduleAddFilePathCopyButtons = () => {
setTimeout(addFilePathCopyButtons, 1000);
return true;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scheduleAddFilePathCopyButtons, { once: true });
} else {
scheduleAddFilePathCopyButtons();
}
}
// Expand all comments/files/etc. on <{Alt,Option}+Shift+E>.
{
const LABEL = "expand all";
const GENERIC_WAITER_SELECTOR = ":is([aria-label*=loading i], [class*=spin i])";
const IGNORE_SELECTOR = [
":disabled",
"form:not([class*=pagination]) *",
"dialog *",
"template *",
"details:not([open]) > :not(summary:first-of-type), details:not([open]) > :not(summary:first-of-type) *",
].join(", ");
const SELECTORS = [
// suppressed diff ("Large diffs are not rendered by default.")
{ loader: undefined,
waiter: `[id^="diff-"] ${GENERIC_WAITER_SELECTOR}` },
// hidden conversations/hidden items ("Load more…")
{ loader: `:is(${BUTTON_SELECTOR})[data-disable-with]`,
waiter: `:is(${BUTTON_SELECTOR}).${ID}[data-disable-with][disabled]` },
// collapsed Files Changed file
{ loader: `[data-diff-header-wrapper]:not(:has(+ *)) button:first-child:has(> .octicon-chevron-right)`,
waiter: undefined,
},
// small-line-count diff context expander
{ loader: `.diff-line-row [data-direction=all]:is(${BUTTON_SELECTOR}):not(.${ID})`,
waiter: `.diff-line-row [data-direction=all].${ID}` },
// collapsed PR comment
{ loader: `[data-is-first-collapse-button]:is(${BUTTON_SELECTOR}):has(> .octicon-chevron-right)`,
waiter: undefined },
// outdated/resolved PR comment
{ loader: `review-thread-collapsible:not(.open) ` +
[
`:is(${BUTTON_SELECTOR})`,
"[aria-expanded=false]",
":is([data-target*=collapsible], [data-action*=toggle])",
].join(""),
waiter: `review-thread-collapsible ${GENERIC_WAITER_SELECTOR}` },
];
const CONDITIONAL_SELECTORS = [
// "Load more" button
{ loader: BUTTON_SELECTOR,
shouldClick: el => /\b(?:more|load)\b/i.test(el.textContent) && isVisible(el),
waiter: undefined,
},
// hidden comment (e.g., "Hidden as off-topic")
{ loader: "[data-timeline-event-id] [data-testid='comment-header']:not(:has(+ *))",
shouldClick: el =>
el.textContent.includes("Hidden as") &&
el.querySelector(`:is(${BUTTON_SELECTOR}):has(> .octicon-unfold)`),
waiter: undefined },
];
const LOADER_SELECTOR = `:is(${
SELECTORS.flatMap(desc => desc.loader ? [desc.loader] : []).join(", ") || ":not(*)"
}):not(${IGNORE_SELECTOR})`;
const WAITER_SELECTOR = `:is(${
[...SELECTORS, ...CONDITIONAL_SELECTORS]
.flatMap(desc => desc.waiter ? [desc.waiter] : []).join(", ") || ":not(*)"
}):not(${IGNORE_SELECTOR})`;
const clickInViewport = async el => {
el.scrollIntoView({ behavior: "instant", block: "center" });
await new Promise(resolve => setTimeout(resolve, 0));
el.classList.add(ID);
const evt = new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: el.ownerDocument.defaultView,
});
el.dispatchEvent(evt);
};
const findAndClickLoaders = async function* (T) {
for (;;) {
let keepLooking = false;
const loaders = new Set(document.querySelectorAll(LOADER_SELECTOR));
for (let elLoader of loaders) {
keepLooking = true;
log(LABEL, "loading from", elLoader);
yield await clickInViewport(elLoader);
}
for (const desc of CONDITIONAL_SELECTORS) {
const sel = `:is(${desc.loader}):not(${IGNORE_SELECTOR})`;
const maybeLoaders = [...document.querySelectorAll(sel)].flatMap(el => {
if (loaders.has(el)) return [];
let result = desc.shouldClick(el);
if (!result) return [];
if (result === true) result = el;
if (result.matches(IGNORE_SELECTOR)) return [];
loaders.add(result);
return [result];
});
for (const elLoader of maybeLoaders) {
keepLooking = true;
log(LABEL, "loading from", elLoader);
yield await clickInViewport(elLoader);
}
}
if (!keepLooking) {
await new Promise(resolve => setTimeout(resolve, T / 10));
const waiters = [...document.querySelectorAll(WAITER_SELECTOR)].filter(isVisible);
if (!waiters.length) break;
log(LABEL, "waiting on", waiters);
waiters
.at(Math.random() * waiters.length)
?.scrollIntoView({ behavior: "instant", block: "center" });
}
await new Promise(resolve => setTimeout(resolve, T));
}
};
let isRunning = false;
const onKeyDown = async ({key, altKey, ctrlKey, metaKey, shiftKey, target}) => {
// Exit quickly and cheaply if the meta keys are wrong.
if (!(altKey && shiftKey && !ctrlKey && !metaKey)) return;
// Normalize to overcome native composition such as "é".
const simpleKey = key.normalize("NFD").charAt(0).toUpperCase();
if (simpleKey !== "E") return;
if (isRunning) return log(LABEL, "already running");
isRunning = true;
let href, node, aborter, signal;
try {
href = location.href;
node =
getSelection?.()?.focusNode ??
document.querySelector(":target") ??
target;
if (node.nodeType === 3) node = node.parentNode;
aborter = new AbortController();
signal = aborter.signal;
const elAbort = createElement("button", { type: "button" }, ["🗙"]);
addPassiveListener(elAbort, "click", () => {
if (!signal.aborted) aborter.abort(elAbort);
});
const elProgress = createElement(
"div",
{ class: `${ID}-running` },
[createElement("span", {}, ["Expanding..."]), elAbort],
);
const t0 = performance.now();
signal.addEventListener("abort", () => {
const milliseconds = Math.round(performance.now() - t0);
if (signal.reason !== elAbort) {
const seconds = Math.round(milliseconds / 1000);
const duration = milliseconds < 500 ? { milliseconds } : { seconds };
elProgress.firstElementChild?.replaceChildren(
`Expanding... done! ${new Intl.DurationFormat().format(duration)}`,
);
}
let fadeOutKit = withResolvers();
fadeOutKit.promise.then(() => { elProgress.remove(); });
elProgress.addEventListener("transitionrun", () => {
elProgress.addEventListener("transitionend", fadeOutKit.resolve);
elProgress.addEventListener("transitioncancel", fadeOutKit.resolve);
fadeOutKit = null;
}, { once: true });
elProgress.classList.add("done");
setTimeout(() => { fadeOutKit?.resolve(); }, 1000);
}, { once: true });
document.body.appendChild(elProgress);
console.time(`[${ID}] ${LABEL}`);
const workPoolConfig = { mode: "allSettled", capacity: 6, signal };
await makeWorkPool(findAndClickLoaders(1200), workPoolConfig);
console.timeEnd(`[${ID}] ${LABEL}`);
} catch (err) {
if (!signal?.aborted) error(err);
} finally {
isRunning = false;
if (!signal?.aborted) aborter?.abort();
node?.scrollIntoView?.({ behavior: "instant" });
const newHref = location.href;
if (href && newHref !== href && newHref.replace(/#.*/, "") === href.replace(/#.*/, "")) {
location.replace(href);
}
}
};
const passive = { __proto__: null, passive: true };
document.addEventListener("keydown", onKeyDown, passive);
const selTypeable = `textarea, input:not(${
"button checkbox image radio range reset submit".split(" ").map(t => `[type=${t}]`)
})`;
document.addEventListener("focusin", ({ target }) => {
document.removeEventListener("keydown", onKeyDown, passive);
if (target.matches(selTypeable)) {
log("disabled document keydown listener");
} else {
document.addEventListener("keydown", onKeyDown, passive);
}
});
document.addEventListener("focusout", ({ target, relatedTarget }) => {
if (!target.matches(selTypeable)) return;
if (!relatedTarget?.matches(selTypeable)) {
log("enabling document keydown listener");
document.addEventListener("keydown", onKeyDown, passive);
}
});
}
function findMatches (nodes, selector) {
return new Set([...nodes].flatMap(el => [
...(el.matches?.(selector) ? [el] : []),
...(el.querySelectorAll?.(selector) || []),
]));
}
function forEachMutation(target, options, callback) {
// As a convenience, invoke a childList callback with initial state but no other context.
if (options.childList) {
callback({ type: "childList", target, addedNodes: target.childNodes }, undefined, undefined);
}
let observer = new MutationObserver(mutations => {
mutations = [...mutations];
let i = 0;
for (const mutation of mutations) {
// Invoke the callback, ignoring the rest of mutations if it returns truthy.
if (callback(mutation, i++, mutations)) return;
}
});
observer.observe(target, options);
return () => {
observer?.disconnect();
observer = null;
};
}
function makeWorkPool(source, config, processInput = x => x) {
// Validate arguments.
if (typeof config !== "object" || !config) config = { capacity: config, mode: undefined };
const { capacity = 10, mode = "all", signal } = config;
if (!(capacity === Infinity || (Number.isInteger(capacity) && capacity > 0))) {
throw RangeError("capacity must be a positive integer");
}
if (!(mode === "all" || mode === "allSettled")) throw RangeError("mode must be 'all' or 'allSettled'");
if (signal?.aborted) {
const dummyResults = (async function* () {})();
dummyResults.throw(signal.reason);
const reasonP = Promise.reject(reason);
const dummyDoneForwarder = defineName("then", (...args) => apply(reasonP.then, reasonP, args));
defineProperty(dummyResults, "then", { ...dataProperty, value: dummyDoneForwarder });
return dummyResults;
}
// Translate source into an async iterator of [index, input] pairs.
const inputs = (async function* (i = 0) {
for await (const input of source) yield [i++, input];
})();
signal?.addEventListener("abort", reason => { void applyInfallible(inputs.throw, inputs, [reason]); });
// Concurrently consume up to `capacity` inputs, pushing the result of
// processing each into a linked chain of promises before consuming
// more.
let tailKit = withResolvers();
const doneKit = withResolvers();
let pending = 0;
const DUMMY = { nextP: undefined, i: -1, result: undefined };
const pushResult = (i, status, value, reason) => {
pending--;
const result =
// Analogous to `Promise.allSettled`, mode "allSettled" produces
// { status, value, reason } records.
mode === "allSettled" ? { status, value, reason } :
// Analogous to `Promise.all`, mode "all" produces unwrapped fulfillment
// values (or fails with the first rejection).
status === "fulfilled" ? value : DUMMY;
if (result === DUMMY) {
void applyInfallible(inputs.throw, inputs, [reason]);
tailKit.reject(reason);
doneKit.reject(reason);
}
const resolveCurrent = tailKit.resolve;
tailKit = withResolvers();
resolveCurrent({ nextP: tailKit.promise, i, result });
take();
};
let n = 0;
const take = async () => {
while (pending < capacity) {
pending++;
// Take an input, quarantining failure.
let caught;
const inputP = inputs.next().catch(err => { caught = [err]; return { done: true, value: undefined }; });
const { done, value: inputData } = await inputP;
if (done) {
if (caught) {
// Fail the chain upon inability to read an input.
pending = NaN;
tailKit.reject(caught[0]);
doneKit.reject(caught[0]);
} else if (--pending <= 0) {
// Finish the chain only when pending drops to zero (if it's still positive,
// we'll get another chance in each future `take`).
pending = NaN;
tailKit.resolve({ ...DUMMY });
doneKit.resolve(n);
}
return;
}
// Asynchronously process the input, and when done, settle the then-current
// tail promise, rejecting it (and failing the chain) when processing fails in
// mode "all" but otherwise fulfilling it with a record that includes the source
// index to which it corresponds and a reference to a new [unsettled] successor
// (thereby extending the chain) and then trying to consume more input via `take()`.
const [i, input] = inputData;
n++;
(async x => processInput(x))(input).then(
result => pushResult(i, "fulfilled", result),
err => pushResult(i, "rejected", undefined, err),
);
}
};
take();
// Provide results as an async iterator of [index, result] pairs in result fulfillment
// order.
// Source order can be recovered with consumer code like
// const results = [];
// for (const [i, result] of workPool(...)) results[i] = result;
// or something more sophisticated to eagerly detect complete subsequences immediately above
// a previous high-water mark.
const results = (async function* (nextP) {
for (;;) {
const { nextP: successor, i, result } = await nextP;
nextP = successor;
if (!successor) break;
yield [i, result];
}
})(tailKit.promise);
for (const name of ["return", "throw"]) {
const builtin = results[name];
const forwarder = defineName(name, (...args) => {
void applyInfallible(inputs[name], inputs, args);
return apply(builtin, results, args);
});
defineProperty(results, name, { ...dataProperty, value: forwarder });
}
// Make the result awaitable.
const doneForwarder = defineName("then", (...args) => apply(doneKit.promise.then, doneKit.promise, args));
defineProperty(results, "then", { ...dataProperty, value: doneForwarder });
return results;
}
function createElement(tagName, attrs, children) {
const el = typeof tagName === "string"
? document.createElement(tagName)
: document.createElementNS(tagName[0], tagName[1]);
if (attrs) {
for (const [name, value] of toEntries(attrs)) el.setAttribute(name, value);
}
if (children) {
for (const child of children) {
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
}
return el;
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment