// ==UserScript== // @name Github: unfold commit history // @namespace http://github.com/johan/ // @description Adds "unfold all changesets" buttons (hotkey: f) above/below Commit History pages at github, letting you browse the source changes without leaving the page. (Click a commit header again to re-fold it.) You can also fold or unfold individual commits by clicking on non-link parts of the commit. As a bonus, all named commits get their tag/branch names annotated in little bubbles on the right. // @match https://*/*/compare* // @match https://*/*/commits* // @match https://*/*/search* // @version 2.0.6 // ==/UserScript== // see end of file for unsandboxing code if (location.hostname.slice(0, 7) === 'github.') (function exit_sandbox() { // manual host check - @match patterns only handle *.github.com, not github.* :( // FIXME: enabled_css + disabled_css + github_css? var hot = 'data-key' // used to find links with hotkey assignments , features = // Problem: where committer != author is the norm, you can't scan for either! // // So instead of aligning both left (and below each other) divide the page in // the middle -- ALWAYS showing authors on the left / committers on the right. { compact_committers: // the text "(committer)" is the hot spot to toggle this feature on and off: { toggle_selector: '#commit .human .actor .name span:contains("committer")' , css: [ '#commit .human .actor { width: 50%; float: left; }' // overrides github , '.compact_committers #commit .human .actor:nth-of-type(odd) {' + ' text-align: right; clear: none; }' // committer name , '.compact_committers #commit .human .actor:nth-of-type(odd) .gravatar {' + ' float: right; margin: 0 0 0 0.7em; }' // committer icon ] } // Problem: it's impossible to tell which commits are tiny and which are huge // // So let's show a little diff line on the right once we have the data loaded // which shows how many lines were added/removed, and across how many files. , change_counts_for_folded_commits_too: { always_enabled: true , css: [ '#commit .folded .machine { padding-bottom: 0; }' , '#commit .machine #toc .diffstat { border: 0; padding: 1px 0 0; }' , '#commit .machine #toc .diffstat-bar { opacity: 0.75; }' , '#commit .machine #toc .diffstat-summary { font-weight: normal; }' , '#commit .envelope.selected .machine #toc span { border-bottom: 0; }' , '#commit .machine #toc {' + ' float: right; width: 1px; margin: 0; border: 0; }' ] , show_diff: function called_from_inline_changeset(commit) { function count() { ++FILES; var lines = /(\d+) additions? & (\d+) deletion/.exec(this.title||''); if (lines) { ADD += Number(lines[1]); // lines added DEL += Number(lines[2]); // lines deleted } } var $m = $('.machine', commit) , already_changed = $m.find('#toc').length; if (already_changed) return; var ADD = 0, DEL = 0, FILES = 0, BLOBS = 5, $a = $m.append('diff' + '
' + '' + ' |
([^<]*)/);
if (got) {
github_api.token = got[1];
dispatch(github_api.pending_token);
delete github_api.pending_token;
}
}
});
}
}
// calls cb({ tag1: hash1, ... }, '/repo/name') after fetching the repo's tags,
// of if none, no_tags('/repo/name')
function get_tags(cb, no_tags, refresh) {
return get_named('tags', cb, no_tags, refresh);
}
// calls cb({ branch: hash1, ... }, '/repo/name') or, no_branches('/repo/name')
// (just like get_tags)
function get_branches(cb, no_branches, refresh) {
return get_named('branches', cb, no_branches, refresh);
}
// returns an array of all `what` ('tag' or 'branch') names in this repository
function get_commitish_names(what) {
function name(i, a) { return $(a).text(); }
what = what.replace(/e?s$/, ''); // also grok 'tags' and 'branches'
return $('.commitish-selector .commitish-item.'+ what +
'-commitish a').map(name).get();
}
// returns true if the resource came straight from its cache.
function get_named(what, cb, no_cb, refresh) {
function got_names(names) {
// cache the repository's tags/branches for later
var json = window.localStorage[path] = JSON.stringify(names = names[what]);
if (json.length > 2)
cb(names, repo);
else
no_cb && no_cb(repo);
}
function get_name() { return this.textContent.replace(/ \u2713$/, ''); }
var repo = window.location.pathname.match(/^(?:\/[^\/]+){2}/);
if (repo) repo = repo[0]; else return false;
var path = what + repo
// the tag or branch names we have cached, if any, or false, for "nothing"
, xxxs = window.localStorage[path] && JSON.parse(window.localStorage[path])
// all tag or branch names listed in the current page
, page = get_commitish_names(what).sort() || []
// all tag or branch names we have already (0+)
, have = xxxs && keys(xxxs).sort() || []
, at_b = 'branches' === what && get_current_branch();
// invalidate the branch cache if we're at the head of a branch, and its hash
// contradicts what we have saved
if (!xxxs || at_b && xxxs[at_b] !== get_first_commit_hash()) refresh = true;
// optimization - if there are none in this repository, don't go fetch any
if ('tags' === what && !page.length) {
have = page;
xxxs = {};
refresh = false;
}
// assume the repo still has no names if it didn't at the time the page loaded
if (page.length === 0)
no_cb && no_cb(repo);
// assume the cache is still good if it's got the same tag number and names
else if (!refresh &&
have.length === page.length &&
have.join() === page.join())
cb(xxxs, repo);
else { // refresh the cache
github_api('/api/v2/json/repos/show'+ repo +'/'+ what, got_names);
return true;
}
return false;
}
//function get_master_branch() {
// return $('.subnav-bar a.switcher').data('masterBranch');
//}
// needs to work everywhere we use the API
function get_current_user() {
return $('#user .name').text();
}
function get_current_branch() {
return $('.subnav-bar a.switcher').data('ref');
}
function get_first_commit_hash() {
return $('.site .commit a['+ hot +'="c"]')[0].pathname.slice(-40);
}
function get_browse_link(hash) {
return $('.commit .commit-meta a.browse-button[href$="/tree/'+ hash +'"]');
}
function get_commit(hash) {
return $('#c_'+ hash);
}
// annotates commits with tag/branch names in little bubbles on the right side
function inject_commit_names() {
function draw_names(type, names, repo) {
var all_names = keys(names); // kin_re => [all names matching kin_re]
all_names.sort().forEach(function(name) {
var hash = names[name]
, url = repo +'/commits/'+ name
, sel = 'a.magic.'+ type +'[href="'+ url +'"]'
, $ci = get_commit(hash) // new location for this tag / branch
, hcls = 'commit-refs'
, $has = $ci.find('.'+ hcls)
;
if ($ci.parent().find(sel).length) return; // it's already rendered here
if (!$has.length)
$has = $ci.append('').find('.'+ hcls);
$(sel).remove(); // remove tag / branch from prior location (if any)
$has.append( ''
+ ''+ name +''
);
// if we just linked a tag, also link a tag changeset, if applicable:
if (type === 'tag') {
var last_tag = get_next_tag(all_names, name, -1);
if (last_tag)
$has.append( 'Δ'
);
}
});
}
function draw_tags(tags, repo) {
draw_names('tag', tags, repo);
}
function draw_branches(branches, repo) {
draw_names('branch', branches, repo);
}
var refresh = get_branches(wrap(draw_branches, 'draw_branches'));
// assume it's best to refresh tags too if any branches were moved
get_tags(wrap(draw_tags, 'draw_tags'), null, refresh);
}
function wrap(fn, name) {
return function timed() {
ENTER(name);
var result = fn.apply(this, arguments);
LEAVE(name);
return result;
};
}
// tries to deliver the next (offset=1) or previous (offset=-1) tag in tags
function get_next_tag(tags, tag, offset) {
// kin_re => [all tags matching kin_re]
var cache = get_next_tag.kin_cache = get_next_tag.kin_cache || {}
, kin_re = quote_re(tag).replace(/(-|\\\.|\d+)+/g, '(-|\\\\\\.|\\d+)+')
, similar = new RegExp(kin_re)
, kin_tags = cache[kin_re] = cache[kin_re] ||
( tags.filter(function(tag) { return similar.test(tag); })
.sort().sort(dwim_sort_func)
)
, this_idx = kin_tags.indexOf(tag)
, want_tag = this_idx + (offset || -1)
;
return kin_tags[want_tag];
}
function quote_re( re ) {
return re.replace( /([.*+^$?(){}|\x5B-\x5D])/g, "\\$1" ); // 5B-5D == [\]
}
// example usage: ['0.10', '0.9'].sort(dwim_sort_func) comes out ['0.9', '0.10']
function dwim_sort_func(a, b) {
if (a === b) return 0;
var int_str_rest_re = /^(\d*)(\D*)(.*)/
, A = int_str_rest_re.exec(a), a_int, a_str, a_int_len = A[1].length
, B = int_str_rest_re.exec(b), b_int, b_str, b_int_len = B[1].length
;
if (!a_int_len ^ !b_int_len) return a_int_len ? -1 : 1;
do {
if ((a_int = A[1]) !==
(b_int = B[1])) {
if ((a_int = parseInt(a_int, 10)) !==
(b_int = parseInt(b_int, 10)))
return a_int < b_int ? -1 : 1;
}
if ((a_str = A[2]) !==
(b_str = B[2]))
return a_str < b_str ? -1 : 1;
a = A[3];
b = B[3];
if (!a.length) return b.length ? -1 : 0;
if (!b.length) return a.length ? 1 : 0;
A = int_str_rest_re.exec(a);
B = int_str_rest_re.exec(b);
} while (true);
}
// make all commits get @id:s c_, and all parent links get @rel=""
function prep_parent_links() {
function hash(a) {
return a.pathname.slice(a.pathname.lastIndexOf('/') + 1);
}
$('.commit:not([id]) a[href]['+ hot +'="p"]').each(function reroute() {
$(this).attr('rel', hash(this));
});
$('.commit:not([id]) a[href]['+ hot +'="c"]').each(function set_id() {
var id = hash(this), ci = $(this).closest('.commit'), pr = ci.prev();
if (pr.find('a['+ hot +'="p"][href$='+ id +']').length)
pr.addClass('adjacent');
ci.attr('id', 'c_' + id);
});
}
function try_scroll_first(wrappee, link_type) {
function normal() { return wrappee.apply(self, args); }
var args = _slice.call(arguments, 1), self = this;
if (link_type !== 'p') return normal();
var link = GitHub.Commits.selected().find('['+ hot +'="'+ link_type +'"]')[0];
// scroll_to_related returns true if link is not in the current view
if (link && scroll_to_related.call(link) &&
confirm('Parent commit not in view -- load parent page instead?'))
return normal();
return false;
}
function scroll_to_related(e) {
var to = $('#c_'+ this.rel);
if (!to.length) return true;
select(this.rel, true);
return false;
}
// hilight the related commit changeset, when a commit link is hovered
function hilight_related(e) {
$('#c_'+ this.rel).addClass('selected');
}
function unlight_related(e) {
$('#c_'+ this.rel).removeClass('selected');
if (null != GitHub.Commits.current)
GitHub.Commits.select(GitHub.Commits.current);
}
// FIXME: integrate with the features blob
function show_docs(x) {
var docs =
{ f: '(un)Fold selected (or all, if none)'
, d: 'Describe selected (or all, if none)'
};
for (var key in docs)
$('#facebox .shortcuts .columns:first .column.middle dl:last')
.before('- '+ key +'
' +
'- '+ docs[key] +'
');
return x;
}
function init_config() {
var $body = $('body');
for (var name in features) {
var feature = features[name]
, enabled = feature.enabled =
feature.always_enabled || !!window.localStorage.getItem(name);
if (enabled) $('body').addClass(name);
if (feature.toggle_selector)
$body.on
( { click: toggle_option
, hover: show_docs_for
}
, feature.toggle_selector
, { option: name }
);
}
}
// an "option toggle" element in the page was clicked; make it so!
function toggle_option(e) {
var name = e.data.option
, feature = features[name];
if ((feature.enabled = !window.localStorage.getItem(name)))
window.localStorage.setItem(name, '1');
else
window.localStorage.removeItem(name);
if (feature.toggle_callback)
feature.toggle_callback(feature.enabled);
$('body').toggleClass(name);
show_docs_for.apply(this, arguments);
return false; // capture the click so it doesn't also cause a fold or unfold
}
function show_docs_for(e) {
var name = e.data.option
, state = !!window.localStorage.getItem(name)
, other = state ? 'off' : 'on'
, title = 'Click to toggle option "'+ name.replace(/_/g, ' ') +'" '+ other;
$(features[name].toggle_selector)
.css('cursor', 'pointer')
.attr('title', title);
}
function toggle_selected_folding() {
var selected = $('.selected');
if (selected.length)
selected.click();
else
toggle_all_folding();
}
function download_selected() {
var selected = $('.selected' + all);
if (selected.length)
selected.each(inline_changeset);
else
download_all();
}
function toggle_all_folding() {
if ($('body').hasClass('all_folded'))
unfold_all();
else
fold_all();
}
function download_all() {
$(all).each(inline_changeset);
}
function unfold_all() {
$('body').addClass('all_unfolded').removeClass('all_folded');
$('.commit.folded').removeClass('folded');
$(all).each(inline_and_unfold);
}
function fold_all() {
$('body').addClass('all_folded').removeClass('all_unfolded');
$('.commit').addClass('folded');
}
// click to fold / unfold, and select:
function toggle_commit_folding(e) {
if (isNotLeftButton(e) ||
$(e.target).closest('a[href], .changeset, .gravatar').length)
return; // clicked a link, or in the changeset; don't do fold action
ENTER('toggle_commit_folding');
// .magic and *# links aren't github commit links (but stuff we added)
var $link = $('.commit-title a[href^="/"][href*="/commit/"]'+ plain, this);
if ($link.hasClass('loaded'))
$(this).toggleClass('folded');
else
$link.each(inline_and_unfold);
select($($(this).closest('.commit')), !'scroll');
LEAVE('toggle_commit_folding');
}
// pass a changeset node, id or hash and have github select it for us
function select(changeset, scroll) {
var node = changeset, nth;
if ('string' === typeof changeset)
node = $('#'+ (/^c_/.test(changeset) ? '' : 'c_') + changeset);
nth = $('.commit').index(node);
pageCall('GitHub.Commits.select', nth);
if (scroll) setTimeout(function() {
var focused = $('.commit.selected');
//if (focused.offset().top - $(window).scrollTop() + 50 > $(window).height())
focused.scrollTo(200);
}, 50);
}
function pageCall(fn/*, arg, ... */) {
var args = JSON.stringify(_slice.call(arguments, 1)).slice(1, -1);
location.href = 'javascript:void '+ fn +'('+ args +')';
}
// every mouse click is not interesting; return true only on left mouse clicks
function isNotLeftButton(e) {
// IE has e.which === null for left click && mouseover, FF has e.which === 1
return (e.which > 1) || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey;
}
function pluralize(noun, n) {
return n +' '+ noun + (n == 1 ? '' : 's');
}
function inline_and_unfold() {
var $c = $(this).closest('.commit');
inline_changeset.call(this, function() { $c.removeClass('folded'); });
}
var _slice = Array.prototype.slice;
function array(ish) {
return _slice.call(ish, 0);
}
function n(x) {
if (x > (1e9 - 5e7 - 1)) return Math.round(x / 1e9) +'G';
if (x > (1e6 - 5e4 - 1)) return Math.round(x / 1e6) +'M';
if (x > (1e3 - 5e1 - 1)) return Math.round(x / 1e3) +'k';
return x + '';
}
// loads the changeset link's full commit message, toc and the files changed and
// inlines them in the corresponding changeset (in the current page)
function inline_changeset(doneCallback) {
// make file header click toggle showing file contents (except links @ right)
function toggle_file(e) {
if (isNotLeftButton(e) || $(e.target).closest('.actions').length)
return; // wrong kind of mouse click, or a right-side action link click
$(this).parent().toggleClass('folded');
}
// diff links for this commit should refer to this commit only
function fix_link() {
var old = this.id;
this.id += '-' + sha1;
changeset.find('a[href="#'+ old +'"]')
.attr('href', '#'+ this.id);
$('div.meta', this).click(toggle_file)
.css('cursor', 'pointer')
.attr('title', 'Toggle showing of file')
.find('.actions').attr('title', ' '); // but don't over-report that title
}
// find all diff links and fix them, annotate how many files were changed, and
// insert line 2.. of the commit message in the unfolded view of the changeset
function post_process() {
github_inlined_comments(this);
var files = changeset.find('[id^="diff-"]').each(fix_link), line2
, diffs = features.change_counts_for_folded_commits_too;
if (diffs.enabled) diffs.show_diff(commit);
// now, add lines 2.. of the commit message to the unfolded changeset view
var whole = $('#commit', changeset); // contains the whole commit message
try {
if ((line2 = $('.message pre', whole).html().replace(line1, ''))) {
$('.human .message pre', commit).append(
$('').html(line2)); // commit message
$('.human .message pre a.loaded:last-child' + plain, commit).after(
'');
}
} catch(e) {} // if this fails, fail silent -- no biggie
whole.remove(); // and remove the remaining duplicate parts of that commit
commit.removeClass('loading'); // remove throbber
if ('function' === typeof doneCallback) doneCallback();
}
var line1 = /^[^\n]*/,
sha1 = this.pathname.slice(this.pathname.lastIndexOf('/') + 1),
commit = $(this).closest('.commit').addClass('loading folded');
$(this).addClass('loaded'); // mark that we already did load it on this page
commit.find('.human, .machine')
.css('cursor', 'pointer');
var changeset = commit
.append('')
.find('.changeset') // ,#all_commit_comments removed from next line
.load(this.href + '.html #toc,#files', post_process); // (.explain, too?)
}
// Makes a function that can replace wrappee that instead calls wrapper(wrappee)
// plus all the args wrappee should have received. (If wrapper does not want the
// original function to run, it does not have to.)
function AOP_wrap_around(wrapper, wrappee) {
return function() {
return wrapper.apply(this, [wrappee].concat(array(arguments)));
};
}
// replace with a function that returns fn(name(...))
function AOP_also_call(name, fn) {
location.href = 'javascript:try {'+ name +' = (function(orig) {\n' +
'return function() {\n' +
'var res = orig.apply(this, arguments);\n' +
'return ('+ (fn.toString()) +')(res);' +
'};' +
'})('+ name +')} finally {void 0}';
}
function on_dom_change(selector, cb) {
function pause_to_tweak_dom() {
$(selector).unbind('DOMSubtreeModified', wrapped_callback);
try { cb(); } catch(e) {};
$(selector).bind('DOMSubtreeModified', wrapped_callback);
}
var wrapped_callback = when_settled(pause_to_tweak_dom);
$(selector).bind('DOMSubtreeModified', wrapped_callback);
}
// drop calls until at least (or 100) ms apart, then pass the last on to cb
function when_settled(cb, ms) {
function is_settled() {
waiter = last = null;
cb.apply(self, args);
};
ms = ms || 100;
var last, waiter, self, args;
return function () {
self = this;
args = arguments;
if (waiter) clearTimeout(waiter);
waiter = setTimeout(is_settled, ms);
};
}
// Github handlers (from http://assets1.github.com/javascripts/bundle_github.js)
// - this is all probably prone to die horribly as the site grows features, over
// time, unless this functionality gets absorbed and maintained by github later.
// In other words, everything below is really just the minimum copy-paste needed
// from the site javascript for inline comments to work -- minimal testing done.
// 5:th $(function) in http://assets1.github.com/javascripts/bundle_github.js,
// but with $() selectors scoped to a "self" node passed from the caller above.
// On unfolding changeset pages with inline comments, we need to make them live,
// as github itself is loading them dynamically after DOMContentLoaded.
function github_inlined_comments(self) {
$(".inline-comment-placeholder", self).each(function () {
var c = $(this);
$.get(c.attr("remote"), function got_comment_form(page) {
page = $(page);
c.closest("tr").replaceWith(page);
github_comment_form(page);
github_comment(page.find(".comment"));
});
});
$("#files .show-inline-comments-toggle", self).change(function () {
this.checked ? $(this).closest(".file").find("tr.inline-comments").show()
: $(this).closest(".file").find("tr.inline-comments").hide();
}).change();
$("#inline_comments_toggle input", self).change(function () {
this.checked ? $("#comments").removeClass("only-commit-comments")
: $("#comments").addClass("only-commit-comments");
}).change();
}
// http://assets1.github.com/javascripts/bundle_github.js::e(c)
function github_comment_form(c) {
c.find("ul.inline-tabs").tabs();
c.find(".show-inline-comment-form a").click(function () {
c.find(".inline-comment-form").show();
$(this).hide();
return false;
});
var b = c.find(".previewable-comment-form")
.previewableCommentForm().closest("form");
b.submit(function () {
b.find(".ajaxindicator").show();
b.find("button").attr("disabled", "disabled");
b.ajaxSubmit({
success: function (f) {
var h = b.closest(".clipper"),
d = h.find(".comment-holder");
if (d.length == 0)
d = h.prepend($(''))
.find(".comment-holder");
f = $(f);
d.append(f);
github_comment(f);
b.find("textarea").val("");
b.find(".ajaxindicator").hide();
b.find("button").attr("disabled", "");
}
});
return false;
});
}
// http://assets1.github.com/javascripts/bundle_github.js::a(c)
function github_comment(c) {
c.find(".relatize").relatizeDate();
c.editableComment();
}
// This block of code injects our source in the content scope and then calls the
// passed callback there. The whole script runs in both GM and page content, but
// since we have no other code that does anything, the Greasemonkey sandbox does
// nothing at all when it has spawned the page script, which gets to use jQuery.
// (jQuery unfortunately degrades much when run in Mozilla's javascript sandbox)
if ('object' === typeof opera && opera.extension) {
this.__proto__ = window; // bleed the web page's js into our execution scope
document.addEventListener('DOMContentLoaded', init, false); // GM-style init
}
else { // for Chrome or Firefox+Greasemonkey
if ('undefined' == typeof __RUNNING_IN_PAGE__) { // unsandbox, please!
var src = exit_sandbox + '',
script = document.createElement('script');
script.setAttribute('type', 'application/javascript');
script.innerHTML = 'const __RUNNING_IN_PAGE__ = true;\n('+ src +')();';
document.documentElement.appendChild(script);
document.documentElement.removeChild(script);
} else { // unsandboxed -- here we go!
init();
}
}
})();