Skip to content

Instantly share code, notes, and snippets.

@gibson042
Last active May 1, 2019 22:27
Show Gist options
  • Save gibson042/7bcdf82113eee77652740db5b9c8eb78 to your computer and use it in GitHub Desktop.
Save gibson042/7bcdf82113eee77652740db5b9c8eb78 to your computer and use it in GitHub Desktop.
GitHub Expand Previous user script
// ==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