Github: unfold commit history

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.

  1. // ==UserScript==
  2. // @name Github: unfold commit history
  3. // @namespace http://github.com/johan/
  4. // @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.
  5. // @include https://github.com/*/commits*
  6. // @include http://github.com/*/commits*
  7. // @match https://github.com/*/commits*
  8. // @match http://github.com/*/commits*
  9. // @version 1.7
  10. // ==/UserScript==
  11.  
  12. (function exit_sandbox() { // see end of file for unsandboxing code
  13.  
  14. var toggle_options = // flip switches you configure by clicking in the UI here:
  15. { compact_committers: '#commit .human .actor .name span:contains("committer")'
  16. , chain_adjacent_connected_commits: '#commit > .separator > h2'
  17. , iso_times: '#commit .human .actor .date > abbr'
  18. , author_filter: '.commit .human .actor:nth-child(2) .gravatar > img'
  19. }, toggle =
  20. { author_filter: function(on) { $('#filtered_authors').attr('disabled',!on); }
  21. }, options = // other options you have to edit this file for:
  22. { changed: true // Shows files changed, lines added / removed in folded mode
  23. }, at = '.commit.loading .machine a[hotkey="c"]',
  24. url = '/images/modules/browser/loading.gif',
  25. plain = ':not(.magic):not([href*="#"])',
  26. all = '.envelope.commit .message a:not(.loaded)'+ plain,
  27. css = // used for .toggleClass('folded'), for, optionally, hiding:
  28. '.file.folded > .data,\n' + // individual .commit .changeset .file:s
  29. '.file.folded > .image,\n' + // (...or their corresponding .image:s)
  30. '.commit.folded .changeset,\n' + // whole .commit:s' diffs,
  31. '.commit.folded .message .full' + // + full checkin message
  32. ' { display: none; }\n' +
  33. '.chain_adjacent_connected_commits #commit .adjacent.commit:not(.selected)' +
  34. ':not(:last-child) { border-bottom-color: transparent; }\n' +
  35. at +':before\n { content: url("'+ url +'"); }\n'+ // show "loading" throbber
  36. at +'\n { position: absolute; margin: 1px 0 0 -70px; height: 14px; }\n' +
  37. '#commit .selected.loading .machine > span:nth-child(1) { border: none; }\n' +
  38. '#commit .machine { padding-left: 14px; padding-bottom: 0; }\n' +
  39.  
  40. // The site has a .site { width: 920px } but #commit .human { width: 50em; },
  41. // which looks bad in Opera, where it becomes about 650px only. Address this:
  42. '#commit .human { width: 667px; }\n' +
  43.  
  44. '.fold_unfold, .download_all { float: right; }\n' +
  45. '.all_folded .fold_unfold:before { content: "\xAB un"; }\n' +
  46. '.all_folded .fold_unfold:after { content: " \xBB"; }\n' +
  47. '.all_unfolded .fold_unfold:before { content: "\xBB "; }\n' +
  48. '.all_unfolded .fold_unfold:after { content: " \xAB"; }\n' +
  49. '#commit .human .message pre { width: auto; }\n' + // don't wrap before EOL!
  50. '.folded .message .truncated:after { content: " (\u2026)"; }\n' +
  51.  
  52. '#commit .human .actor { width: 50%; float:left; }\n' +
  53. '.compact_committers #commit .human .actor:nth-of-type(odd) {' +
  54. ' text-align: right; clear: none; }\n' +
  55. '.compact_committers #commit .human .actor:nth-of-type(odd) .gravatar {' +
  56. ' float: right; margin: 0 0 0 0.7em; }\n' +
  57.  
  58. 'body:not(.iso_times) .date > .iso { display: none; }\n' +
  59. '.iso_times .date > .relatize.relatized:before { content: "("; }\n' +
  60. '.iso_times .date > .relatize.relatized:after { content: ")"; }\n' +
  61. '.iso_times .date > .relatize.relatized { display: inline; }\n' +
  62. '.iso_times .date > .relatize { display: none; }\n' +
  63.  
  64. 'body:not(.author_filter) #author_filter { display: none; }\n' +
  65. '#author_filter img.filtered { opacity: 0.5; }' +
  66. '#author_filter img { margin: 0 .3em 0 0; background-color: white; '+
  67. ' padding: 2px; border: 1px solid #D0D0D0; }' +
  68.  
  69. '.iso_times .date > .relatize.relatized:after { content: ")"; }\n' +
  70. '.iso_times .date > .relatize.relatized { display: inline; }\n' +
  71. '.iso_times .date > .relatize { display: none; }\n' +
  72.  
  73. '.magic.tag, .magic.branch { opacity: 0.75; }' +
  74. '.message .tag { background: #FE7; text-align: right; padding: 0 2px; ' +
  75. ' margin: 0 -5px .1em 0; border-radius: 4px; float: right; clear: both; }\n' +
  76. '.message .branch { background: #7EF; text-align: right; padding: 0 2px; ' +
  77. ' margin: 0 -5px .1em 0; border-radius: 4px; float: right; clear: both; }\n' +
  78.  
  79. '.magic.tag.diff { clear: left; margin-right: 0.25em; }\n' + // Δ marker
  80.  
  81. (!options.changed ? '' :
  82. '#commit .folded .machine { padding-bottom: 0; }\n' +
  83. '#commit .machine #toc .diffstat { border: 0; padding: 1px 0 0; }\n' +
  84. '#commit .machine #toc .diffstat-bar { opacity: 0.75; }\n' +
  85. '#commit .machine #toc .diffstat-summary { font-weight: normal; }\n'+
  86. '#commit .envelope.selected .machine #toc span { border-bottom: 0; }\n' +
  87. '#commit .machine #toc { float: right; width: 1px; margin: 0; border: 0; }');
  88.  
  89. var on_page_change =
  90. [ prep_parent_links
  91. , inject_commit_names
  92. ], keys = Object.keys || function _keys(o) {
  93. var r = [], k;
  94. for (k in o) if (o.hasOwnProperty(k)) r.push(k);
  95. return r;
  96. };
  97.  
  98. // Run first at init, and then once per (settled) page change, for later updates
  99. // caused by stuff like AutoPagerize.
  100. function onChange() {
  101. on_page_change.forEach(function(cb, x) { cb(); });
  102. }
  103.  
  104. function init() {
  105. $('body').addClass('all_folded') // preload the loading throbber, so it shows
  106. .append('<img src="'+ url +'" style="visibility:hidden;">'); // up promptly
  107. $('head').append($('<style type="text/css"></style>').html(css));
  108. $('.commit').live('click', toggle_commit_folding);
  109.  
  110. onChange();
  111. $(document).bind('DOMNodeInserted', when_settled(onChange), false);
  112. $('a[href][hotkey=p]')
  113. .live('mouseover', null, hilight_related)
  114. .live('mouseout', null, unlight_related)
  115. .live('click', null, scroll_to_related); // if triggered by mouse click,
  116. // scroll to the commit if it's in view, otherwise load that page instead --
  117. // and ditto but for trigger by keyboard hotkey instead (falls back to link):
  118. GitHub.Commits.link = AOP_wrap_around(try_scroll_first, GitHub.Commits.link);
  119.  
  120. init_config();
  121.  
  122. $('<div class="pagination" style="margin: 0; padding: 0;"></div>')
  123. .prependTo('#commit .separator:first');
  124. $('<a class="download_all" hotkey="d"><u>d</u>ecorate all</a>')
  125. .appendTo('.pagination').click(download_all);
  126. $('<a class="fold_unfold" hotkey="f"><u>f</u>old all</a>')
  127. .appendTo('.pagination');
  128. $('.fold_unfold').toggle(unfold_all, fold_all);
  129.  
  130. // export to public identifiers for the hotkeys
  131. window.toggle_selected_folding = toggle_selected_folding;
  132. window.toggle_all_folding = toggle_all_folding;
  133. window.download_selected = download_selected;
  134. window.download_all = download_all;
  135. location.href = 'javascript:$.hotkeys(' +
  136. '{ f: toggle_selected_folding' +
  137. //', F: toggle_all_folding' +
  138. ', d: download_selected' +
  139. //', D: download_all' +
  140. '});' + // adds our own hotkeys
  141. 'delete GitHub.Commits.elements;' + // makes j / k span demand-loaded pages
  142. 'GitHub.Commits.__defineGetter__("elements",' +
  143. 'function() { return $(".commit"); });void 0';
  144.  
  145. setTimeout(function() { AOP_also_call('$.facebox.reveal', show_docs); }, 1e3);
  146.  
  147. render_author_filter();
  148. }
  149.  
  150. // makes all authors in the view show up on top; on page load, or page update
  151. function render_author_filter(e) {
  152. if (e) { // postpone reruns until 100ms passed without activity
  153. if (render_author_filter.scheduled)
  154. clearTimeout(render_author_filter.scheduled);
  155. render_author_filter.scheduled = setTimeout(render_author_filter, 100, 0);
  156. return;
  157. }
  158. delete render_author_filter.scheduled;
  159.  
  160. if (!$('#author_filter').length) {
  161. $('#path').after('<div id="author_filter"></div>');
  162. $('#commit').bind('DOMSubtreeModified', render_author_filter);
  163. }
  164. $('.commit:not(.by) .human .actor:nth-child(2) .gravatar > img')
  165. .each(update_author_filter); // find not-yet-catered commits
  166. }
  167.  
  168. // makes a particular commit in the view get counted and added to author filter
  169. function update_author_filter(e) {
  170. var mail_hash = /avatar\/([a-f\d]{32})/.exec(this.src)
  171. , author_id = 'author_'+ mail_hash[1]
  172. , $gravatar = $('#'+ author_id)
  173. , commit_no = parseInt($gravatar.attr('title') || '0', 10)
  174. , $envelope = !commit_no && $(this).parents('.actor');
  175. if ($envelope) {
  176. var img = this.cloneNode(true);
  177. img.alt = $envelope.find('.name').text().replace(/\s*\(author\)\s*$/, '');
  178. img.id = author_id;
  179. $('#author_filter').append(img);
  180. $(img).click(toggle_author_commits);
  181. img.title = ++commit_no +' commit by '+ img.alt;
  182. $('head').append('<style id="filtered_authors"></style>');
  183. }
  184. else
  185. $gravatar.attr('title',
  186. ++commit_no +' commits by '+ $gravatar.attr('alt'));
  187. $(this).parents('.commit').addClass('by '+author_id);
  188. $('#'+ author_id).css('padding-right', (1 + commit_no) +'px');
  189. }
  190.  
  191. function toggle_author_commits(e) {
  192. $(this).toggleClass('filtered');
  193. var hide = $('#author_filter .filtered').map(function() { return this.id; });
  194. if (hide.length) hide = '.'+ (array(hide).join(',.')) +' { display: none; }';
  195. else hide = '';
  196. $('#filtered_authors').html(hide);
  197. }
  198.  
  199. // fetch some API resource by api
  200. function github_api(path, cb) {
  201. function get() {
  202. if (1 === enqueue().length)
  203. $.ajax(request);
  204. }
  205. function enqueue() {
  206. var queue = github_api[path] = github_api[path] || [];
  207. queue.push(cb); // always modify in place for dispatch
  208. return queue;
  209. }
  210. function dispatch(queue, args) {
  211. for (var i = 0, cb; cb = queue[i]; i++)
  212. cb.apply(this, args || []);
  213. }
  214. var logged_in = github_api.token || $('#header a[href="/logout"]').length
  215. , request =
  216. { url: path
  217. , success: function done() {
  218. dispatch(github_api[path], arguments);
  219. delete github_api[path];
  220. }
  221. , dataType: 'json'
  222. , beforeSend: logged_in && function(xhr) {
  223. var name = $('#header .avatarname .name').text()
  224. , auth = btoa(name+'/token:'+ github_api.token);
  225. xhr.setRequestHeader('Authorization', 'Basic '+ auth);
  226. }
  227. };
  228. if (!logged_in || github_api.token)
  229. get();
  230. else if (github_api.pending_token)
  231. github_api.pending_token.push(get);
  232. else {
  233. github_api.pending_token = [get];
  234. $.ajax({ url: '/account/admin'
  235. , beforeSend: function(xhr) { xhr.withCredentials = true; }
  236. , success: function(html) {
  237. var got = html.match(/API token is <code>([^<]*)/);
  238. if (got) {
  239. github_api.token = got[1];
  240. dispatch(github_api.pending_token);
  241. delete github_api.pending_token;
  242. }
  243. }
  244. });
  245. }
  246. }
  247.  
  248. // calls cb({ tag1: hash1, ... }, '/repo/name') after fetching the repo's tags,
  249. // of if none, no_tags('/repo/name')
  250. function get_tags(cb, no_tags, refresh) {
  251. return get_named('tags', cb, no_tags, refresh);
  252. }
  253.  
  254. // calls cb({ branch: hash1, ... }, '/repo/name') or, no_branches('/repo/name')
  255. // (just like get_tags)
  256. function get_branches(cb, no_branches, refresh) {
  257. return get_named('branches', cb, no_branches, refresh);
  258. }
  259.  
  260. function get_named(what, cb, no_cb, refresh) {
  261. function got_names(names) {
  262. // cache the repository's tags/branches for later
  263. var json = window.localStorage[path] = JSON.stringify(names = names[what]);
  264. if (json.length > 2)
  265. cb(names, repo);
  266. else
  267. no_cb && no_cb(repo);
  268. }
  269. function get_name() { return this.textContent.replace(/ \u2713$/, ''); }
  270.  
  271. var repo = window.location.pathname.match(/^(?:\/[^\/]+){2}/);
  272. if (repo) repo = repo[0]; else return false;
  273.  
  274. var path = what + repo
  275. , xxxs = window.localStorage[path] && JSON.parse(window.localStorage[path])
  276. , _css = '.subnav-bar '+ (what === 'tags' ? 'li + li' : 'li:first-child')
  277. , page = $(_css + ' a.dropdown + ul > li').map(get_name).get().sort() || []
  278. , have = xxxs && keys(xxxs).sort() || []
  279. , at_b = 'branches' === what && get_current_branch();
  280.  
  281. // invalidate the branch cache if we're at the head of a branch, and its hash
  282. // contradicts what we have saved
  283. if (!xxxs || at_b && xxxs[at_b] !== get_first_commit_hash()) refresh = true;
  284.  
  285. // optimization - if there are no tags in the page, don't go fetch any
  286. if ('tags' === what && !page.length) {
  287. have = page;
  288. xxxs = {};
  289. refresh = false;
  290. }
  291.  
  292. // assume the repo still has no names if it didn't at the time the page loaded
  293. if (page.length === 0)
  294. no_cb && no_cb(repo);
  295. // assume the cache is still good if it's got the same tag number and names
  296. else if (!refresh &&
  297. have.length === page.length &&
  298. have.join() === page.join())
  299. cb(xxxs, repo);
  300. else { // refresh the cache
  301. github_api('/api/v2/json/repos/show'+ repo +'/'+ what, got_names);
  302. return true;
  303. }
  304. return false;
  305. }
  306.  
  307. function get_current_branch() {
  308. return $('.subnav-bar li:first-child ul li strong').text().slice(0, -2);
  309. }
  310.  
  311. function get_first_commit_hash() {
  312. return $('#commit .commit .machine a[hotkey="c"]')[0].pathname.slice(-40);
  313. }
  314.  
  315. // annotates commits with tag/branch names in little bubbles on the right side
  316. function inject_commit_names() {
  317. function draw_names(type, names, repo) {
  318. var all_names = keys(names)
  319. , kin_cache = {}; // kin_re => [all names matching kin_re]
  320. all_names.sort().forEach(function(name) {
  321. var hash = names[name]
  322. , url = repo +'/commits/'+ name
  323. , sel = 'a.'+ type +'[href="'+ url +'"]'
  324. , $a = $('.commit pre > a[href$="'+ repo +'/commit/'+ hash +'"]');
  325. if (!$a.parent().find(sel).length) { // does the commit exist in the page?
  326. $(sel).remove(); // remove tag / branch from prior location (if any)
  327. $a.before('<a class="magic '+type+'" href="'+ url +'">'+ name +'</a>');
  328.  
  329. // if we just linked a tag, also link a tag changeset, if applicable:
  330. if (type !== 'tag') return;
  331. var kin_re = quote_re(name).replace(/\d+/g, '\\d+')
  332. , similar = new RegExp(kin_re)
  333. , kin_tags = kin_cache[similar] = kin_cache[similar] ||
  334. ( all_names
  335. .filter(function(tag) { return similar.test(tag); })
  336. .sort(dwim_sort_func)
  337. )
  338. , this_idx = kin_tags.indexOf(name)
  339. , last_tag = this_idx && kin_tags[this_idx - 1];
  340. if (last_tag)
  341. $a.before( '<a class="magic tag diff" title="Changes since '+ last_tag
  342. + '" href="'+ repo +'/compare/'+ last_tag +'...'+ name +'">'
  343. + '&Delta;</a>'
  344. );
  345. }
  346. });
  347. }
  348. function draw_tags(tags, repo) {
  349. draw_names('tag', tags, repo);
  350. }
  351. function draw_branches(branches, repo) {
  352. draw_names('branch', branches, repo);
  353. }
  354. var refresh = get_branches(draw_branches);
  355. // assume it's best to refresh tags too if any branches were moved
  356. get_tags(draw_tags, null, refresh);
  357. }
  358.  
  359. function quote_re( re ) {
  360. return re.replace( /([.*+^$?(){}|\x5B-\x5D])/g, "\\$1" ); // 5B-5D == [\]
  361. }
  362.  
  363. // example usage: ['0.10', '0.9'].sort(dwim_sort_func) comes out ['0.9', '0.10']
  364. function dwim_sort_func(a, b) {
  365. if (a === b) return 0;
  366. var int_str_rest_re = /^(\d*)(\D*)(.*)/
  367. , A = int_str_rest_re.exec(a), a_int, a_str, a_int_len = A[1].length
  368. , B = int_str_rest_re.exec(b), b_int, b_str, b_int_len = B[1].length
  369. ;
  370. if (!a_int_len ^ !b_int_len) return a_int_len ? -1 : 1;
  371. do {
  372. if ((a_int = A[1]) !==
  373. (b_int = B[1])) {
  374. if ((a_int = parseInt(a_int, 10)) !==
  375. (b_int = parseInt(b_int, 10)))
  376. return a_int < b_int ? -1 : 1;
  377. }
  378.  
  379. if ((a_str = A[2]) !==
  380. (b_str = B[2]))
  381. return a_str < b_str ? -1 : 1;
  382.  
  383. a = A[3];
  384. b = B[3];
  385. if (!a.length) return b.length ? -1 : 0;
  386. if (!b.length) return a.length ? 1 : 0;
  387.  
  388. A = int_str_rest_re.exec(a);
  389. B = int_str_rest_re.exec(b);
  390. } while (true);
  391. }
  392.  
  393. // make all commits get @id:s c_<hash>, and all parent links get @rel="<hash>"
  394. function prep_parent_links() {
  395. function hash(a) {
  396. return a.pathname.slice(a.pathname.lastIndexOf('/') + 1);
  397. }
  398. $('.commit:not([id]) a[href][hotkey=p]').each(function reroute() {
  399. $(this).attr('rel', hash(this));
  400. });
  401. $('.commit:not([id]) a[href][hotkey=c]').each(function set_id() {
  402. var id = hash(this), ci = $(this).closest('.commit'), pr = ci.prev();
  403. if (pr.find('a[hotkey=p][href$='+ id +']').length) pr.addClass('adjacent');
  404. ci.attr('id', 'c_' + id);
  405. });
  406.  
  407. $('.date > abbr.relatize:first-child').each(unrelatize_dates);
  408. }
  409.  
  410. function unrelatize_dates() {
  411. var ts = this.title, at = new Date(ts.replace(/-/g,'/')), t = ts.split(' ')[1]
  412. , wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][at.getDay()];
  413. $(this).before('<abbr class="iso" title="'+ ts +'">'+ wd +' '+ t +' </abbr>');
  414. }
  415.  
  416. function try_scroll_first(wrappee, link_type) {
  417. function normal() { return wrappee.apply(self, args); }
  418. var args = _slice.call(arguments, 1), self = this;
  419. if (link_type !== 'p') return normal();
  420.  
  421. var link = GitHub.Commits.selected().find('[hotkey="'+ link_type +'"]')[0];
  422. // scroll_to_related returns true if link is not in the current view
  423. if (link && scroll_to_related.call(link) &&
  424. confirm('Parent commit not in view -- load parent page instead?'))
  425. return normal();
  426. return false;
  427. }
  428.  
  429. function scroll_to_related(e) {
  430. var to = $('#c_'+ this.rel);
  431. if (!to.length) return true;
  432. select(this.rel, true);
  433. return false;
  434. }
  435.  
  436. // hilight the related commit changeset, when a commit link is hovered
  437. function hilight_related(e) {
  438. $('#c_'+ this.rel).addClass('selected');
  439. }
  440.  
  441. function unlight_related(e) {
  442. $('#c_'+ this.rel).removeClass('selected');
  443. if (null != GitHub.Commits.current)
  444. GitHub.Commits.select(GitHub.Commits.current);
  445. }
  446.  
  447. function show_docs(x) {
  448. var docs =
  449. { f: '(un)Fold selected (or all, if none)'
  450. , d: 'Describe selected (or all, if none)'
  451. };
  452. for (var key in docs)
  453. $('#facebox .shortcuts .columns:first .column.middle dl:last')
  454. .before('<dl class="keyboard-mappings"><dt>'+ key +'</dt>' +
  455. '<dd>'+ docs[key] +'</dd></dl>');
  456. return x;
  457. }
  458.  
  459.  
  460. function init_config() {
  461. for (var o in toggle_options) {
  462. if ((options[o] = !!window.localStorage.getItem(o)))
  463. $('body').addClass(o);
  464. $(toggle_options[o])
  465. .live('click', { option: o }, toggle_option)
  466. .live('hover', { option: o }, show_docs_for);
  467. }
  468. }
  469.  
  470. function toggle_option(e) {
  471. var o = e.data.option, cb, toggle_fn = toggle[o];
  472. if ((options[o] = !window.localStorage.getItem(o))) {
  473. window.localStorage.setItem(o, '1');
  474. if (toggle_fn) toggle_fn(true);
  475. }
  476. else {
  477. window.localStorage.removeItem(o);
  478. if (toggle_fn) toggle_fn(false);
  479. }
  480. $('body').toggleClass(o);
  481. show_docs_for.apply(this, arguments);
  482. return false; // do not fold / unfold
  483. }
  484.  
  485. function show_docs_for(e) {
  486. var o = e.data.option;
  487. var is = !!window.localStorage.getItem(o);
  488. $(this).css('cursor', 'pointer')
  489. .attr('title', 'Click to toggle option "' + o.replace(/_/g, ' ') +'" '+
  490. (is ? 'off' : 'on'));
  491. }
  492.  
  493. function toggle_selected_folding() {
  494. var selected = $('.selected');
  495. if (selected.length)
  496. selected.click();
  497. else
  498. toggle_all_folding();
  499. }
  500.  
  501. function download_selected() {
  502. var selected = $('.selected' + all);
  503. if (selected.length)
  504. selected.each(inline_changeset);
  505. else
  506. download_all();
  507. }
  508.  
  509. function toggle_all_folding() {
  510. if ($('body').hasClass('all_folded'))
  511. unfold_all();
  512. else
  513. fold_all();
  514. }
  515.  
  516. function download_all() {
  517. $(all).each(inline_changeset);
  518. }
  519.  
  520. function unfold_all() {
  521. $('body').addClass('all_unfolded').removeClass('all_folded');
  522. $('.commit.folded').removeClass('folded');
  523. $(all).each(inline_and_unfold);
  524. }
  525.  
  526. function fold_all() {
  527. $('body').addClass('all_folded').removeClass('all_unfolded');
  528. $('.commit').addClass('folded');
  529. }
  530.  
  531. // click to fold / unfold, and select:
  532. function toggle_commit_folding(e) {
  533. if (isNotLeftButton(e) ||
  534. $(e.target).closest('a[href], .changeset, .gravatar').length)
  535. return; // clicked a link, or in the changeset; don't do fold action
  536.  
  537. // .magic and *# links aren't github commit links (but stuff we added)
  538. var $link = $('.message a[href*="/commit/"]'+ plain, this);
  539. if ($link.hasClass('loaded'))
  540. $(this).toggleClass('folded');
  541. else
  542. $link.each(inline_and_unfold);
  543.  
  544. select($($(this).closest('.commit')), !'scroll');
  545. }
  546.  
  547. // pass a changeset node, id or hash and have github select it for us
  548. function select(changeset, scroll) {
  549. var node = changeset, nth;
  550. if ('string' === typeof changeset)
  551. node = $('#'+ (/^c_/.test(changeset) ? '' : 'c_') + changeset);
  552. nth = $('.commit').index(node);
  553. pageCall('GitHub.Commits.select', nth);
  554. if (scroll) setTimeout(function() {
  555. var focused = $('.commit.selected');
  556. //if (focused.offset().top - $(window).scrollTop() + 50 > $(window).height())
  557. focused.scrollTo(200);
  558. }, 50);
  559. }
  560.  
  561. function pageCall(fn/*, arg, ... */) {
  562. var args = JSON.stringify(_slice.call(arguments, 1)).slice(1, -1);
  563. location.href = 'javascript:void '+ fn +'('+ args +')';
  564. }
  565.  
  566. // every mouse click is not interesting; return true only on left mouse clicks
  567. function isNotLeftButton(e) {
  568. // IE has e.which === null for left click && mouseover, FF has e.which === 1
  569. return (e.which > 1) || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey;
  570. }
  571.  
  572. function pluralize(noun, n) {
  573. return n +' '+ noun + (n == 1 ? '' : 's');
  574. }
  575.  
  576. function inline_and_unfold() {
  577. var $c = $(this).closest('.commit');
  578. inline_changeset.call(this, function() { $c.removeClass('folded'); });
  579. }
  580.  
  581. var _slice = Array.prototype.slice;
  582. function array(ish) {
  583. return _slice.call(ish, 0);
  584. }
  585.  
  586. function n(x) {
  587. if (x > (1e9 - 5e7 - 1)) return Math.round(x / 1e9) +'G';
  588. if (x > (1e6 - 5e4 - 1)) return Math.round(x / 1e6) +'M';
  589. if (x > (1e3 - 5e1 - 1)) return Math.round(x / 1e3) +'k';
  590. return x + '';
  591. }
  592.  
  593. // loads the changeset link's full commit message, toc and the files changed and
  594. // inlines them in the corresponding changeset (in the current page)
  595. function inline_changeset(doneCallback) {
  596. // make file header click toggle showing file contents (except links @ right)
  597. function toggle_file(e) {
  598. if (isNotLeftButton(e) || $(e.target).closest('.actions').length)
  599. return; // wrong kind of mouse click, or a right-side action link click
  600. $(this).parent().toggleClass('folded');
  601. }
  602.  
  603. // diff links for this commit should refer to this commit only
  604. function fix_link() {
  605. var old = this.id;
  606. this.id += '-' + sha1;
  607. changeset.find('a[href="#'+ old +'"]')
  608. .attr('href', '#'+ this.id);
  609. $('div.meta', this).click(toggle_file)
  610. .css('cursor', 'pointer')
  611. .attr('title', 'Toggle showing of file')
  612. .find('.actions').attr('title', ' '); // but don't over-report that title
  613. }
  614.  
  615. function show_changed() {
  616. var $m = $('.machine', commit), alreadyChanged = $m.find('#toc').length;
  617. if (alreadyChanged) return;
  618. var F = 0, A = 0, D = 0, $a = $m.append('diff' +
  619. '<table id="toc"><tbody><tr><td class="diffstat">' +
  620. '<a class="tooltipped leftwards"></a>' +
  621. '</td></tr></tbody></table>').find('#toc a');
  622.  
  623. // count added / removed lines and number of files changed
  624. $('.changeset #toc .diffstat a[title]', commit).each(function count() {
  625. ++F; // files touched
  626. var lines = /(\d+) additions? & (\d+) deletion/.exec(this.title || '');
  627. if (lines) {
  628. A += Number(lines[1]); // lines added
  629. D += Number(lines[2]); // lines deleted
  630. }
  631. });
  632.  
  633. var text = '<b>+'+ n(A) +'</b> / <b>-'+ n(D) +'</b> in <b>'+ n(F) +'</b>',
  634. stat = '<span class="diffstat-summary">'+ text +'</span>\n', i, N = 5,
  635. plus = Math.round(A / (A + D) * N), bar = '<span class="diffstat-bar">';
  636. // don't show more blobs than total lines, and show ties as even # of blobs
  637. if (A + D < N) { plus = A; N = A + D; } else if (A === D) { --plus; --N; }
  638. for (i = 0; i < N; i++)
  639. bar += '<span class="'+ (i < plus ? 'plus' : 'minus') +'">\u2022</span>';
  640. bar += '</span>';
  641.  
  642. $a.html(stat + bar).attr('title', A +' additions & '+ D +' deletions in '+
  643. pluralize('file', F));
  644. }
  645.  
  646. // find all diff links and fix them, annotate how many files were changed, and
  647. // insert line 2.. of the commit message in the unfolded view of the changeset
  648. function post_process() {
  649. github_inlined_comments(this);
  650.  
  651. var files = changeset.find('[id^="diff-"]').each(fix_link), line2;
  652.  
  653. if (options.changed) show_changed();
  654.  
  655. // now, add lines 2.. of the commit message to the unfolded changeset view
  656. var whole = $('#commit', changeset); // contains the whole commit message
  657. try {
  658. if ((line2 = $('.message pre', whole).html().replace(line1, ''))) {
  659. $('.human .message pre', commit).append(
  660. $('<span class="full"></span>').html(line2)); // commit message
  661. $('.human .message pre a.loaded' + plain, commit).after(
  662. '<span title="Message continues..." class="truncated"></span>');
  663. }
  664. } catch(e) {} // if this fails, fail silent -- no biggie
  665. whole.remove(); // and remove the remaining duplicate parts of that commit
  666.  
  667. commit.removeClass('loading'); // remove throbber
  668. if ('function' === typeof doneCallback) doneCallback();
  669. }
  670.  
  671. var line1 = /^[^\n]*/,
  672. sha1 = this.pathname.slice(this.pathname.lastIndexOf('/') + 1),
  673. commit = $(this).closest('.commit').addClass('loading folded');
  674. $(this).addClass('loaded'); // mark that we already did load it on this page
  675. commit.find('.human, .machine')
  676. .css('cursor', 'pointer');
  677. var changeset = commit
  678. .append('<div class="changeset" style="float: left; width: 100%;"/>')
  679. .find('.changeset') // ,#all_commit_comments removed from next line
  680. .load(this.href + '.html #commit,#toc,#files', post_process);
  681. }
  682.  
  683. // Makes a function that can replace wrappee that instead calls wrapper(wrappee)
  684. // plus all the args wrappee should have received. (If wrapper does not want the
  685. // original function to run, it does not have to.)
  686. function AOP_wrap_around(wrapper, wrappee) {
  687. return function() {
  688. return wrapper.apply(this, [wrappee].concat(array(arguments)));
  689. };
  690. }
  691.  
  692. // replace <name> with a function that returns fn(name(...))
  693. function AOP_also_call(name, fn) {
  694. location.href = 'javascript:try {'+ name +' = (function(orig) {\n' +
  695. 'return function() {\n' +
  696. 'var res = orig.apply(this, arguments);\n' +
  697. 'return ('+ (fn.toString()) +')(res);' +
  698. '};' +
  699. '})('+ name +')} finally {void 0}';
  700. }
  701.  
  702. // drop calls until at least <ms> (or 100) ms apart, then pass the last on to cb
  703. function when_settled(cb, ms) {
  704. function is_settled() {
  705. waiter = last = null;
  706. cb.apply(self, args);
  707. };
  708.  
  709. ms = ms || 100;
  710. var last, waiter, self, args;
  711.  
  712. return function () {
  713. self = this;
  714. args = arguments;
  715. if (waiter) clearTimeout(waiter);
  716. waiter = setTimeout(is_settled, 100);
  717. };
  718. }
  719.  
  720. // Github handlers (from http://assets1.github.com/javascripts/bundle_github.js)
  721. // - this is all probably prone to die horribly as the site grows features, over
  722. // time, unless this functionality gets absorbed and maintained by github later.
  723.  
  724. // In other words, everything below is really just the minimum copy-paste needed
  725. // from the site javascript for inline comments to work -- minimal testing done.
  726.  
  727. // 5:th $(function) in http://assets1.github.com/javascripts/bundle_github.js,
  728. // but with $() selectors scoped to a "self" node passed from the caller above.
  729. // On unfolding changeset pages with inline comments, we need to make them live,
  730. // as github itself is loading them dynamically after DOMContentLoaded.
  731. function github_inlined_comments(self) {
  732. $(".inline-comment-placeholder", self).each(function () {
  733. var c = $(this);
  734. $.get(c.attr("remote"), function got_comment_form(page) {
  735. page = $(page);
  736. c.closest("tr").replaceWith(page);
  737. github_comment_form(page);
  738. github_comment(page.find(".comment"));
  739. });
  740. });
  741.  
  742. $("#files .show-inline-comments-toggle", self).change(function () {
  743. this.checked ? $(this).closest(".file").find("tr.inline-comments").show()
  744. : $(this).closest(".file").find("tr.inline-comments").hide();
  745. }).change();
  746.  
  747. $("#inline_comments_toggle input", self).change(function () {
  748. this.checked ? $("#comments").removeClass("only-commit-comments")
  749. : $("#comments").addClass("only-commit-comments");
  750. }).change();
  751. }
  752.  
  753. // http://assets1.github.com/javascripts/bundle_github.js::e(c)
  754. function github_comment_form(c) {
  755. c.find("ul.inline-tabs").tabs();
  756.  
  757. c.find(".show-inline-comment-form a").click(function () {
  758. c.find(".inline-comment-form").show();
  759. $(this).hide();
  760. return false;
  761. });
  762.  
  763. var b = c.find(".previewable-comment-form")
  764. .previewableCommentForm().closest("form");
  765.  
  766. b.submit(function () {
  767. b.find(".ajaxindicator").show();
  768. b.find("button").attr("disabled", "disabled");
  769. b.ajaxSubmit({
  770. success: function (f) {
  771. var h = b.closest(".clipper"),
  772. d = h.find(".comment-holder");
  773. if (d.length == 0)
  774. d = h.prepend($('<div class="inset comment-holder"></div>'))
  775. .find(".comment-holder");
  776. f = $(f);
  777. d.append(f);
  778. github_comment(f);
  779. b.find("textarea").val("");
  780. b.find(".ajaxindicator").hide();
  781. b.find("button").attr("disabled", "");
  782. }
  783. });
  784. return false;
  785. });
  786. }
  787.  
  788. // http://assets1.github.com/javascripts/bundle_github.js::a(c)
  789. function github_comment(c) {
  790. c.find(".relatize").relatizeDate();
  791. c.editableComment();
  792. }
  793.  
  794.  
  795.  
  796. // This block of code injects our source in the content scope and then calls the
  797. // passed callback there. The whole script runs in both GM and page content, but
  798. // since we have no other code that does anything, the Greasemonkey sandbox does
  799. // nothing at all when it has spawned the page script, which gets to use jQuery.
  800. // (jQuery unfortunately degrades much when run in Mozilla's javascript sandbox)
  801. if ('object' === typeof opera && opera.extension) {
  802. this.__proto__ = window; // bleed the web page's js into our execution scope
  803. document.addEventListener('DOMContentLoaded', init, false); // GM-style init
  804. }
  805. else { // for Chrome or Firefox+Greasemonkey
  806. if ('undefined' == typeof __UNFOLD_IN_PAGE_SCOPE__) { // unsandbox, please!
  807. var src = exit_sandbox + '',
  808. script = document.createElement('script');
  809. script.setAttribute('type', 'application/javascript');
  810. script.innerHTML = 'const __UNFOLD_IN_PAGE_SCOPE__ = true;\n('+ src +')();';
  811. document.documentElement.appendChild(script);
  812. document.documentElement.removeChild(script);
  813. } else { // unsandboxed -- here we go!
  814. init();
  815. }
  816. }
  817.  
  818. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址