Last active
June 18, 2026 04:28
-
-
Save gibson042/d8ab425d133400959bc5bb875898a97b to your computer and use it in GitHub Desktop.
GitHub Fixer user script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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