Last active
May 1, 2019 22:27
-
-
Save gibson042/7bcdf82113eee77652740db5b9c8eb78 to your computer and use it in GitHub Desktop.
GitHub Expand Previous user script
This file contains 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 Expand Previous | |
// @namespace https://github.com/gibson042 | |
// @description Adds to GitHub diffs "previous" expansion and Shift+click to expand full block. | |
// @source https://gist.github.com/gibson042/7bcdf82113eee77652740db5b9c8eb78 | |
// @downloadURL https://gist.github.com/gibson042/7bcdf82113eee77652740db5b9c8eb78/raw/github-expand-previous.user.js | |
// @version 0.1.3 | |
// @date 2019-03-16 | |
// @author Richard Gibson <@gmail.com> | |
// @include https://github.*/* | |
// @include https://*.github.*/* | |
// @include http://github.*/* | |
// @include http://*.github.*/* | |
// ==/UserScript== | |
// | |
// I, Richard Gibson, hereby establish my original authorship of this | |
// work, and announce its release into the public domain. I claim no | |
// exclusive copyrights to it, and will neither pursue myself (nor | |
// condone pursuit by others of) punishment, retribution, or forced | |
// payment for its full or partial reproduction in any form. | |
// | |
// That being said, I would like to receive credit for this work | |
// whenever it, or any part thereof, is reproduced or incorporated into | |
// another creation; and would also like compensation whenever revenue | |
// is derived from such reproduction or inclusion. At the very least, | |
// please let me know if you find this work useful or enjoyable, and | |
// contact me with any comments or criticisms regarding it. | |
// | |
// This program is distributed in the hope that it will be useful, | |
// but WITHOUT ANY WARRANTY; without even the implied warranty of | |
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. | |
// | |
// | |
// Changelog: | |
// 0.1.3 (2019-03-16) | |
// Fixed: Avoid errors in non-diff contexts. | |
// 0.1.2 (2019-03-08) | |
// Updated: Compatibility with github.com changes. | |
// 0.1.1 (2018-08-03) | |
// Fixed: Stopped duplicating the following line during full "prev" expansion. | |
// 0.1.0 (2018-07-15) | |
// original release | |
(function (getWindow) { | |
"use strict"; | |
// Define immutable constants. | |
const ID = "gibson042-github-expand-prev"; | |
const strPrevExpanderClass = ID; | |
const selContainer = ".js-expandable-line .blob-num-expandable"; | |
const selExpander = ".js-expand[class*='-expander']"; // .diff-expander.js-expand (GitHub enterprise) or .directional-expander.js-expand (github.com) | |
const selPrevExpander = `${selExpander}.${strPrevExpanderClass}`; | |
const selNextExpander = `${selExpander}:not(.${strPrevExpanderClass})`; | |
const selLoneExpander = `${selContainer} ${selExpander}:only-of-type`; | |
const urlBase = Object.freeze(new URL(location.href)); | |
const diffRangeParams = Object.freeze({ | |
prevLeft: Object.freeze(["prev_line_num_left", "last_left"]), | |
prevRight: Object.freeze(["prev_line_num_right", "last_right"]), | |
nextLeft: Object.freeze(["next_line_num_left", "left"]), | |
nextRight: Object.freeze(["next_line_num_right", "right"]), | |
}); | |
const window = getWindow(); | |
// Define mutable globals. | |
const fetchExpanderData = new Map(); | |
const elHtmlContainer = document.createElement("template"); | |
const continuationUrls = new Set(); | |
let continueExpandAll = false; | |
let abortExpandAll = false; | |
// Do nothing in non-diff contexts. | |
if ( /\.github.io$/i.test(location.hostname) ) { | |
return; | |
} | |
if ( !document.head ) { | |
// Always abort loading, but log an error only for HTML documents (skipping e.g. SVG). | |
const root = document.documentElement; | |
if ( /html/i.test(root.nodeName) ) console.error(new Error(`[${ID}] expected document head`), root); | |
return; | |
} | |
// Insert default styles. | |
// They use the following CSS classes and variables: | |
// * gibson042-github-expand-prev | |
// class for "previous" expanders | |
// * --gibson042-github-expand-prev--expander--horizontal-padding | |
// padding-{right,left} for {"previous","next"} expanders | |
// * --gibson042-github-expand-prev--prev-expander--width | |
// width of "previous" expanders | |
// * --gibson042-github-expand-prev--expander-divider--width | |
// width of the slanted divider between "previous" and "next" expanders | |
// * --gibson042-github-expand-prev--expander-divider--overlap | |
// amount of intrusion by the slanted divider into "previous" expanders | |
(function () { | |
const elStyle = document.createElement("style"); | |
const strVarPrefix = "--" + ID; | |
const strVarHPadding = strVarPrefix + "--expander--horizontal-padding"; | |
const strVarPrevWidth = strVarPrefix + "--prev-expander--width"; | |
const strVarDividerWidth = strVarPrefix + "--expander-divider--width"; | |
const strVarDividerOverlap = strVarPrefix + "--expander-divider--overlap"; | |
elStyle.innerText = ` | |
:root { | |
${strVarHPadding}: 5px; | |
${strVarPrevWidth}: 35px; | |
${strVarDividerWidth}: 20px; | |
${strVarDividerOverlap}: 7px; | |
} | |
${selContainer} { | |
position: relative; | |
} | |
/* "previous" expander */ | |
${selContainer} ${selPrevExpander} { | |
float: left; | |
width: calc(var(${strVarPrevWidth}) + var(${strVarDividerWidth}) - var(${strVarDividerOverlap})); | |
clip-path: polygon(0 0, 100% 0, calc(100% - var(${strVarDividerWidth})) 100%, 0 100%); | |
padding-right: calc(var(${strVarHPadding}) + var(${strVarDividerWidth}) - var(${strVarDividerOverlap})); | |
} | |
/* post-previous "next" expander */ | |
${selContainer} ${selPrevExpander} + ${selNextExpander} { | |
margin-left: calc(var(${strVarPrevWidth}) - var(${strVarDividerOverlap})); | |
padding-left: calc(var(${strVarHPadding}) + var(${strVarDividerWidth}) - var(${strVarDividerOverlap})); | |
clip-path: polygon(var(${strVarDividerWidth}) 0, 100% 0, 100% 100%, 0 100%); | |
} | |
/* "previous"/"next" expander divider */ | |
${selContainer} ${selPrevExpander} + ${selNextExpander}::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
height: 100%; | |
left: calc(var(${strVarPrevWidth}) - var(${strVarDividerOverlap})); | |
width: calc(1px + var(${strVarDividerWidth})); | |
clip-path: polygon(calc(var(${strVarDividerWidth}) - 1px) 0, 100% 0, 2px 100%, 0 100%); | |
background: silver; | |
} | |
`; | |
document.head.insertBefore(elStyle, document.head.children[2]); | |
})(); | |
// Insert a "previous" expander before every new non-final "next" expander. | |
let ignoringMutations = false; | |
function onMutation ( mutations ) { | |
if ( ignoringMutations ) return; | |
ignoringMutations = true; | |
for ( const mutation of mutations) { | |
if ( !mutation.addedNodes.length ) continue; | |
expanders: for ( const elNextExpander of mutation.target.querySelectorAll(selLoneExpander) ) { | |
// Start with a clone of the "next" expander. | |
const elPrevExpander = elNextExpander.cloneNode(true); | |
elPrevExpander.classList.add(strPrevExpanderClass); | |
try { | |
// Extract parameters from the expander URL. | |
const strUrl = elPrevExpander.getAttribute("data-url"); | |
if ( !strUrl ) throw new Error(`[${ID}] missing data-url: ${elNextExpander.outerHTML}`); | |
let params = new URLSearchParams(strUrl.replace(/^[^?]*/, "")); | |
// Skip expanders at start and end that don't separate diff sections. | |
if ( params.get("direction") ) continue; | |
// Noisily skip expanders that don't have parameters describing {prev,next}{Left,Right} lines. | |
for ( const [role, possibleNames] of Object.entries(diffRangeParams) ) { | |
const value = getPreferred(params, possibleNames); | |
if ( !isFinite(+value || NaN) ) { | |
console.warn(`ignoring expander URL for unacceptable ${role} [${possibleNames.join(", ")}] of "${value}":`, strUrl); | |
continue expanders; | |
} | |
} | |
// Remove next{Left,Right} parameters and update the URL. | |
for ( const nextParam of [].concat(diffRangeParams.nextLeft, diffRangeParams.nextRight) ) { | |
params.delete(nextParam); | |
} | |
elPrevExpander.setAttribute("data-url", strUrl.replace(/[?][^]*/, "?" + params)); | |
} catch ( err ) { | |
console.error(err); | |
continue; | |
} | |
// Insert the new expander. | |
elNextExpander.parentNode.insertBefore(elPrevExpander, elNextExpander); | |
// Continue expanding if warranted. | |
if ( continuationUrls.delete(elNextExpander.getAttribute("data-url")) ) { | |
setTimeout(() => { continueExpandAll = true; elNextExpander.click(); }); | |
} | |
} | |
} | |
ignoringMutations = false; | |
} | |
(new MutationObserver(onMutation)).observe(document.body, {childList: true, subtree: true}); | |
// Update the initial page contents, too. | |
onMutation([{addedNodes: [document.body], target: document.body}]); | |
// Insert "next" expanders into the responses from "previous" fetches. | |
document.body.addEventListener("click", function ( evt ) { | |
let forceExpandAll = continueExpandAll; | |
continueExpandAll = false; | |
// Collect normalized url, direction, and "next" expander from expander clicks. | |
let url, isPrev; | |
let el = evt.target; | |
while ( el ) { | |
if ( el.matches && el.matches(selExpander) ) { | |
isPrev = el.matches(selPrevExpander); | |
url = new URL(el.getAttribute("data-url"), urlBase); | |
url.searchParams.sort(); | |
el = el.parentNode; | |
el = el && el.querySelector(selNextExpander); | |
break; | |
} | |
el = el.parentNode; | |
} | |
// Bail out on non-expander clicks. | |
if ( !el ) return; | |
// Store data about this expander request in the fetch map. | |
fetchExpanderData.set(url.href, { | |
url: url, | |
elNextExpander: el, | |
isPrev: isPrev, | |
// Expand all lines if shift is held down. | |
wantsExpandAll: forceExpandAll || evt.shiftKey | |
}); | |
}, true); | |
// Abort iterative expansion when Escape is pressed. | |
document.addEventListener("keydown", evt => evt.keyCode === 27 && (abortExpandAll = true), {passive: true}); | |
// Wrap the global fetch to insert our logic. | |
window.fetch = (function ( fetch ) { | |
return function ( request ) { | |
let strUrl, key, expanderData, tmp; | |
// Pull out expander data for this request from the fetch map. | |
try { | |
const url = new URL(request.url, urlBase); | |
url.searchParams.sort(); | |
strUrl = key = url.href; | |
expanderData = fetchExpanderData.get(key); | |
if ( !expanderData ) { | |
for ( [ key, tmp ] of fetchExpanderData ) { | |
if ( urlContains(url, tmp.url) ) { | |
expanderData = tmp; | |
break; | |
} | |
} | |
} | |
} catch ( err ) { | |
} finally { | |
if ( key ) fetchExpanderData.delete(key); | |
} | |
// Use native fetch to make the request. | |
const ret = fetch.apply(this, arguments); | |
const then = ret && ret.then; | |
// Pass through the result unless we have expander data to use. | |
if ( !expanderData || typeof then !== "function" ) return ret; | |
// Wrap the `text()` getter of a successful response. | |
return then.call(ret, response => { | |
if ( response.status != 200 ) return response; | |
const textGetter = response.text; | |
response.text = Object.assign(function () { | |
const ret = textGetter.apply(this, arguments); | |
const then = ret && ret.then; | |
if ( typeof then !== "function" ) { | |
console.error(new Error(`[${ID}] expected promise`), ret); | |
return ret; | |
} | |
return then.call(ret, htmlProcessorForExpanderData(expanderData)) | |
.catch(err => { console.error(err); throw err }); | |
}, textGetter); | |
return response; | |
}); | |
}; | |
})(window.fetch || fetch); | |
// htmlProcessorForExpanderData returns a function that accepts diff-expansion response text | |
// and returns the portion of it meaningful in the context of its argument. | |
// It also schedules or aborts automatic continuation requests as warranted. | |
function htmlProcessorForExpanderData ( expanderData ) { | |
return function ( responseText ) { | |
// Parse the response as HTML. | |
elHtmlContainer.innerHTML = responseText; | |
const fragment = elHtmlContainer.content; | |
// Remove lines that are already in the DOM. | |
if ( expanderData.isPrev ) { | |
let elRemove; | |
let elAncestor = expanderData.elNextExpander.parentNode; | |
while ( elAncestor ) { | |
// Don't contemplate elements that have too few siblings to be a plausible diff line. | |
let elNextLine = elAncestor.nextElementSibling; | |
if ( elNextLine && elNextLine.parentNode.children.length >= 5 ) { | |
if ( !elNextLine.id ) elNextLine = elNextLine.querySelector("[id]:not([id=''])"); | |
if ( elNextLine.id ) { | |
elRemove = fragment.querySelector("#" + CSS.escape(elNextLine.id)); | |
break; | |
} | |
} | |
elAncestor = elAncestor.parentNode; | |
} | |
while ( elRemove && (elRemove.parentNode || fragment) !== fragment ) { | |
elRemove = elRemove.parentNode; | |
} | |
while ( elRemove && fragment.lastChild ) { | |
let elRemoved = fragment.removeChild(fragment.lastChild); | |
if ( !elRemoved || elRemoved === elRemove ) { | |
break; | |
} | |
} | |
} | |
// Replace the expander. | |
let elIncomingExpander = fragment.querySelector(selLoneExpander); | |
if ( elIncomingExpander && expanderData.isPrev ) { | |
const urlIncoming = new URL(elIncomingExpander.getAttribute("data-url"), urlBase); | |
const newLeftEnd = +getPreferred(urlIncoming.searchParams, diffRangeParams.prevLeft) || NaN; | |
const newRightEnd = +getPreferred(urlIncoming.searchParams, diffRangeParams.prevRight) || NaN; | |
if ( !isFinite(newLeftEnd + newRightEnd) ) throw new Error(`[${ID}] unidentifiable previous range: ${urlIncoming.href}`); | |
// Update the URL. | |
const elExpanderReplacement = expanderData.elNextExpander.cloneNode(true); | |
const strOldUrl = elExpanderReplacement.getAttribute("data-url"); | |
const params = new URLSearchParams(strOldUrl.replace(/^[^?]*/, "")); | |
setPreferred(params, diffRangeParams.prevLeft, newLeftEnd); | |
setPreferred(params, diffRangeParams.prevRight, newRightEnd); | |
elExpanderReplacement.setAttribute("data-url", strOldUrl.replace(/[?][^#]*/, "?" + params)); | |
// Update the other attributes. | |
const leftRange = elExpanderReplacement.getAttribute("data-left-range"); | |
const rightRange = elExpanderReplacement.getAttribute("data-right-range"); | |
if ( /^\d+-\d+$/.test(leftRange) ) elExpanderReplacement.setAttribute("data-left-range", leftRange.replace(/\d+/, newLeftEnd+1)); | |
if ( /^\d+-\d+$/.test(rightRange) ) elExpanderReplacement.setAttribute("data-right-range", rightRange.replace(/\d+/, newRightEnd+1)); | |
// Replace. | |
elIncomingExpander.parentNode.replaceChild(elExpanderReplacement, elIncomingExpander); | |
elIncomingExpander = elExpanderReplacement; | |
} | |
// Handle automatic continuation. | |
if ( abortExpandAll ) { | |
abortExpandAll = false; | |
} else if ( elIncomingExpander && expanderData.wantsExpandAll ) { | |
continuationUrls.add(elIncomingExpander.getAttribute("data-url")); | |
} | |
// Return the resulting HTML. | |
return fragmentToHtml(fragment); | |
} | |
} | |
// fragmentToHtml returns HTML corresponding to a document fragment, using the nonstandard "outerHTML" property on elements. | |
function fragmentToHtml( fragment ) { | |
return Array.prototype.reduce.call(fragment.childNodes, | |
(html, node) => html + ("outerHTML" in node ? node.outerHTML : node.nodeValue), | |
"" | |
); | |
} | |
// urlContains tests if its second URL argument is a subset of its first (i.e., hosts and specified URL query parameters match). | |
function urlContains ( urlLong, urlShort ) { | |
if ( urlShort.host !== urlLong.host ) return false; | |
let lastKey; | |
for ( let [ key, _ ] of urlShort.searchParams ) { | |
if ( key === lastKey ) continue; | |
lastKey = key; | |
const expectedValues = urlShort.searchParams.getAll(key); | |
const actualValues = urlLong.searchParams.getAll(key); | |
if ( actualValues.length !== expectedValues.length ) return false; | |
for ( let i = actualValues.length - 1; i >= 0; i-- ) { | |
if ( actualValues[i] !== expectedValues[i] ) return false; | |
} | |
} | |
return true; | |
} | |
// getPreferred gets a value from a map using a sequence of decreasingly-preferred keys. | |
function getPreferred( map, keys ) { | |
for ( const key of keys ) { | |
if ( map.has(key) ) { | |
return map.get(key); | |
} | |
} | |
} | |
// setPreferred sets a value in a map using a sequence of decreasingly-preferred keys and returns a boolean indicating if it was a replacement. | |
function setPreferred( map, keys, val ) { | |
for ( const key of keys ) { | |
if ( map.has(key) ) { | |
map.set(key, val); | |
return true; | |
} | |
} | |
map.set(keys[0], val); | |
return false; | |
} | |
})(function getWindow () { | |
// Run non-strict for fallback access to the global object via `this`. | |
let window = typeof unsafeWindow === "object" && unsafeWindow; | |
if ( !window || window === (function(){return this;})() ) { | |
const a = document.createElement("a"); | |
try { | |
a.setAttribute("onclick", "return (function(){return this;})();"); | |
window = a.onclick(); | |
} catch( x ) { | |
window = document.defaultView || (function(){return this;})(); | |
} | |
} | |
return window; | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment