Merge branch 'dk/describe-all-output-fix' into maint
[git] / gitweb / static / js / blame_incremental.js
1 // Copyright (C) 2007, Fredrik Kuivinen <frekui@gmail.com>
2 //               2007, Petr Baudis <pasky@suse.cz>
3 //          2008-2011, Jakub Narebski <jnareb@gmail.com>
4
5 /**
6  * @fileOverview JavaScript side of Ajax-y 'blame_incremental' view in gitweb
7  * @license GPLv2 or later
8  */
9
10 /* ============================================================ */
11 /*
12  * This code uses DOM methods instead of (nonstandard) innerHTML
13  * to modify page.
14  *
15  * innerHTML is non-standard IE extension, though supported by most
16  * browsers; however Firefox up to version 1.5 didn't implement it in
17  * a strict mode (application/xml+xhtml mimetype).
18  *
19  * Also my simple benchmarks show that using elem.firstChild.data =
20  * 'content' is slightly faster than elem.innerHTML = 'content'.  It
21  * is however more fragile (text element fragment must exists), and
22  * less feature-rich (we cannot add HTML).
23  *
24  * Note that DOM 2 HTML is preferred over generic DOM 2 Core; the
25  * equivalent using DOM 2 Core is usually shown in comments.
26  */
27
28
29 /* ............................................................ */
30 /* utility/helper functions (and variables) */
31
32 var projectUrl; // partial query + separator ('?' or ';')
33
34 // 'commits' is an associative map. It maps SHA1s to Commit objects.
35 var commits = {};
36
37 /**
38  * constructor for Commit objects, used in 'blame'
39  * @class Represents a blamed commit
40  * @param {String} sha1: SHA-1 identifier of a commit
41  */
42 function Commit(sha1) {
43         if (this instanceof Commit) {
44                 this.sha1 = sha1;
45                 this.nprevious = 0; /* number of 'previous', effective parents */
46         } else {
47                 return new Commit(sha1);
48         }
49 }
50
51 /* ............................................................ */
52 /* progress info, timing, error reporting */
53
54 var blamedLines = 0;
55 var totalLines  = '???';
56 var div_progress_bar;
57 var div_progress_info;
58
59 /**
60  * Detects how many lines does a blamed file have,
61  * This information is used in progress info
62  *
63  * @returns {Number|String} Number of lines in file, or string '...'
64  */
65 function countLines() {
66         var table =
67                 document.getElementById('blame_table') ||
68                 document.getElementsByTagName('table')[0];
69
70         if (table) {
71                 return table.getElementsByTagName('tr').length - 1; // for header
72         } else {
73                 return '...';
74         }
75 }
76
77 /**
78  * update progress info and length (width) of progress bar
79  *
80  * @globals div_progress_info, div_progress_bar, blamedLines, totalLines
81  */
82 function updateProgressInfo() {
83         if (!div_progress_info) {
84                 div_progress_info = document.getElementById('progress_info');
85         }
86         if (!div_progress_bar) {
87                 div_progress_bar = document.getElementById('progress_bar');
88         }
89         if (!div_progress_info && !div_progress_bar) {
90                 return;
91         }
92
93         var percentage = Math.floor(100.0*blamedLines/totalLines);
94
95         if (div_progress_info) {
96                 div_progress_info.firstChild.data  = blamedLines + ' / ' + totalLines +
97                         ' (' + padLeftStr(percentage, 3, '\u00A0') + '%)';
98         }
99
100         if (div_progress_bar) {
101                 //div_progress_bar.setAttribute('style', 'width: '+percentage+'%;');
102                 div_progress_bar.style.width = percentage + '%';
103         }
104 }
105
106
107 var t_interval_server = '';
108 var cmds_server = '';
109 var t0 = new Date();
110
111 /**
112  * write how much it took to generate data, and to run script
113  *
114  * @globals t0, t_interval_server, cmds_server
115  */
116 function writeTimeInterval() {
117         var info_time = document.getElementById('generating_time');
118         if (!info_time || !t_interval_server) {
119                 return;
120         }
121         var t1 = new Date();
122         info_time.firstChild.data += ' + (' +
123                 t_interval_server + ' sec server blame_data / ' +
124                 (t1.getTime() - t0.getTime())/1000 + ' sec client JavaScript)';
125
126         var info_cmds = document.getElementById('generating_cmd');
127         if (!info_time || !cmds_server) {
128                 return;
129         }
130         info_cmds.firstChild.data += ' + ' + cmds_server;
131 }
132
133 /**
134  * show an error message alert to user within page (in progress info area)
135  * @param {String} str: plain text error message (no HTML)
136  *
137  * @globals div_progress_info
138  */
139 function errorInfo(str) {
140         if (!div_progress_info) {
141                 div_progress_info = document.getElementById('progress_info');
142         }
143         if (div_progress_info) {
144                 div_progress_info.className = 'error';
145                 div_progress_info.firstChild.data = str;
146         }
147 }
148
149 /* ............................................................ */
150 /* coloring rows during blame_data (git blame --incremental) run */
151
152 /**
153  * used to extract N from 'colorN', where N is a number,
154  * @constant
155  */
156 var colorRe = /\bcolor([0-9]*)\b/;
157
158 /**
159  * return N if <tr class="colorN">, otherwise return null
160  * (some browsers require CSS class names to begin with letter)
161  *
162  * @param {HTMLElement} tr: table row element to check
163  * @param {String} tr.className: 'class' attribute of tr element
164  * @returns {Number|null} N if tr.className == 'colorN', otherwise null
165  *
166  * @globals colorRe
167  */
168 function getColorNo(tr) {
169         if (!tr) {
170                 return null;
171         }
172         var className = tr.className;
173         if (className) {
174                 var match = colorRe.exec(className);
175                 if (match) {
176                         return parseInt(match[1], 10);
177                 }
178         }
179         return null;
180 }
181
182 var colorsFreq = [0, 0, 0];
183 /**
184  * return one of given possible colors (currently least used one)
185  * example: chooseColorNoFrom(2, 3) returns 2 or 3
186  *
187  * @param {Number[]} arguments: one or more numbers
188  *        assumes that  1 <= arguments[i] <= colorsFreq.length
189  * @returns {Number} Least used color number from arguments
190  * @globals colorsFreq
191  */
192 function chooseColorNoFrom() {
193         // choose the color which is least used
194         var colorNo = arguments[0];
195         for (var i = 1; i < arguments.length; i++) {
196                 if (colorsFreq[arguments[i]-1] < colorsFreq[colorNo-1]) {
197                         colorNo = arguments[i];
198                 }
199         }
200         colorsFreq[colorNo-1]++;
201         return colorNo;
202 }
203
204 /**
205  * given two neighbor <tr> elements, find color which would be different
206  * from color of both of neighbors; used to 3-color blame table
207  *
208  * @param {HTMLElement} tr_prev
209  * @param {HTMLElement} tr_next
210  * @returns {Number} color number N such that
211  * colorN != tr_prev.className && colorN != tr_next.className
212  */
213 function findColorNo(tr_prev, tr_next) {
214         var color_prev = getColorNo(tr_prev);
215         var color_next = getColorNo(tr_next);
216
217
218         // neither of neighbors has color set
219         // THEN we can use any of 3 possible colors
220         if (!color_prev && !color_next) {
221                 return chooseColorNoFrom(1,2,3);
222         }
223
224         // either both neighbors have the same color,
225         // or only one of neighbors have color set
226         // THEN we can use any color except given
227         var color;
228         if (color_prev === color_next) {
229                 color = color_prev; // = color_next;
230         } else if (!color_prev) {
231                 color = color_next;
232         } else if (!color_next) {
233                 color = color_prev;
234         }
235         if (color) {
236                 return chooseColorNoFrom((color % 3) + 1, ((color+1) % 3) + 1);
237         }
238
239         // neighbors have different colors
240         // THEN there is only one color left
241         return (3 - ((color_prev + color_next) % 3));
242 }
243
244 /* ............................................................ */
245 /* coloring rows like 'blame' after 'blame_data' finishes */
246
247 /**
248  * returns true if given row element (tr) is first in commit group
249  * to be used only after 'blame_data' finishes (after processing)
250  *
251  * @param {HTMLElement} tr: table row
252  * @returns {Boolean} true if TR is first in commit group
253  */
254 function isStartOfGroup(tr) {
255         return tr.firstChild.className === 'sha1';
256 }
257
258 /**
259  * change colors to use zebra coloring (2 colors) instead of 3 colors
260  * concatenate neighbor commit groups belonging to the same commit
261  *
262  * @globals colorRe
263  */
264 function fixColorsAndGroups() {
265         var colorClasses = ['light', 'dark'];
266         var linenum = 1;
267         var tr, prev_group;
268         var colorClass = 0;
269         var table =
270                 document.getElementById('blame_table') ||
271                 document.getElementsByTagName('table')[0];
272
273         while ((tr = document.getElementById('l'+linenum))) {
274         // index origin is 0, which is table header; start from 1
275         //while ((tr = table.rows[linenum])) { // <- it is slower
276                 if (isStartOfGroup(tr, linenum, document)) {
277                         if (prev_group &&
278                             prev_group.firstChild.firstChild.href ===
279                                     tr.firstChild.firstChild.href) {
280                                 // we have to concatenate groups
281                                 var prev_rows = prev_group.firstChild.rowSpan || 1;
282                                 var curr_rows =         tr.firstChild.rowSpan || 1;
283                                 prev_group.firstChild.rowSpan = prev_rows + curr_rows;
284                                 //tr.removeChild(tr.firstChild);
285                                 tr.deleteCell(0); // DOM2 HTML way
286                         } else {
287                                 colorClass = (colorClass + 1) % 2;
288                                 prev_group = tr;
289                         }
290                 }
291                 var tr_class = tr.className;
292                 tr.className = tr_class.replace(colorRe, colorClasses[colorClass]);
293                 linenum++;
294         }
295 }
296
297
298 /* ============================================================ */
299 /* main part: parsing response */
300
301 /**
302  * Function called for each blame entry, as soon as it finishes.
303  * It updates page via DOM manipulation, adding sha1 info, etc.
304  *
305  * @param {Commit} commit: blamed commit
306  * @param {Object} group: object representing group of lines,
307  *                        which blame the same commit (blame entry)
308  *
309  * @globals blamedLines
310  */
311 function handleLine(commit, group) {
312         /*
313            This is the structure of the HTML fragment we are working
314            with:
315
316            <tr id="l123" class="">
317              <td class="sha1" title=""><a href=""> </a></td>
318              <td class="linenr"><a class="linenr" href="">123</a></td>
319              <td class="pre"># times (my ext3 doesn&#39;t).</td>
320            </tr>
321         */
322
323         var resline = group.resline;
324
325         // format date and time string only once per commit
326         if (!commit.info) {
327                 /* e.g. 'Kay Sievers, 2005-08-07 21:49:46 +0200' */
328                 commit.info = commit.author + ', ' +
329                         formatDateISOLocal(commit.authorTime, commit.authorTimezone);
330         }
331
332         // color depends on group of lines, not only on blamed commit
333         var colorNo = findColorNo(
334                 document.getElementById('l'+(resline-1)),
335                 document.getElementById('l'+(resline+group.numlines))
336         );
337
338         // loop over lines in commit group
339         for (var i = 0; i < group.numlines; i++, resline++) {
340                 var tr = document.getElementById('l'+resline);
341                 if (!tr) {
342                         break;
343                 }
344                 /*
345                         <tr id="l123" class="">
346                           <td class="sha1" title=""><a href=""> </a></td>
347                           <td class="linenr"><a class="linenr" href="">123</a></td>
348                           <td class="pre"># times (my ext3 doesn&#39;t).</td>
349                         </tr>
350                 */
351                 var td_sha1  = tr.firstChild;
352                 var a_sha1   = td_sha1.firstChild;
353                 var a_linenr = td_sha1.nextSibling.firstChild;
354
355                 /* <tr id="l123" class=""> */
356                 var tr_class = '';
357                 if (colorNo !== null) {
358                         tr_class = 'color'+colorNo;
359                 }
360                 if (commit.boundary) {
361                         tr_class += ' boundary';
362                 }
363                 if (commit.nprevious === 0) {
364                         tr_class += ' no-previous';
365                 } else if (commit.nprevious > 1) {
366                         tr_class += ' multiple-previous';
367                 }
368                 tr.className = tr_class;
369
370                 /* <td class="sha1" title="?" rowspan="?"><a href="?">?</a></td> */
371                 if (i === 0) {
372                         td_sha1.title = commit.info;
373                         td_sha1.rowSpan = group.numlines;
374
375                         a_sha1.href = projectUrl + 'a=commit;h=' + commit.sha1;
376                         if (a_sha1.firstChild) {
377                                 a_sha1.firstChild.data = commit.sha1.substr(0, 8);
378                         } else {
379                                 a_sha1.appendChild(
380                                         document.createTextNode(commit.sha1.substr(0, 8)));
381                         }
382                         if (group.numlines >= 2) {
383                                 var fragment = document.createDocumentFragment();
384                                 var br   = document.createElement("br");
385                                 var match = commit.author.match(/\b([A-Z])\B/g);
386                                 if (match) {
387                                         var text = document.createTextNode(
388                                                         match.join(''));
389                                 }
390                                 if (br && text) {
391                                         var elem = fragment || td_sha1;
392                                         elem.appendChild(br);
393                                         elem.appendChild(text);
394                                         if (fragment) {
395                                                 td_sha1.appendChild(fragment);
396                                         }
397                                 }
398                         }
399                 } else {
400                         //tr.removeChild(td_sha1); // DOM2 Core way
401                         tr.deleteCell(0); // DOM2 HTML way
402                 }
403
404                 /* <td class="linenr"><a class="linenr" href="?">123</a></td> */
405                 var linenr_commit =
406                         ('previous' in commit ? commit.previous : commit.sha1);
407                 var linenr_filename =
408                         ('file_parent' in commit ? commit.file_parent : commit.filename);
409                 a_linenr.href = projectUrl + 'a=blame_incremental' +
410                         ';hb=' + linenr_commit +
411                         ';f='  + encodeURIComponent(linenr_filename) +
412                         '#l' + (group.srcline + i);
413
414                 blamedLines++;
415
416                 //updateProgressInfo();
417         }
418 }
419
420 // ----------------------------------------------------------------------
421
422 /**#@+
423  * @constant
424  */
425 var sha1Re = /^([0-9a-f]{40}) ([0-9]+) ([0-9]+) ([0-9]+)/;
426 var infoRe = /^([a-z-]+) ?(.*)/;
427 var endRe  = /^END ?([^ ]*) ?(.*)/;
428 /**@-*/
429
430 var curCommit = new Commit();
431 var curGroup  = {};
432
433 /**
434  * Parse output from 'git blame --incremental [...]', received via
435  * XMLHttpRequest from server (blamedataUrl), and call handleLine
436  * (which updates page) as soon as blame entry is completed.
437  *
438  * @param {String[]} lines: new complete lines from blamedata server
439  *
440  * @globals commits, curCommit, curGroup, t_interval_server, cmds_server
441  * @globals sha1Re, infoRe, endRe
442  */
443 function processBlameLines(lines) {
444         var match;
445
446         for (var i = 0, len = lines.length; i < len; i++) {
447
448                 if ((match = sha1Re.exec(lines[i]))) {
449                         var sha1 = match[1];
450                         var srcline  = parseInt(match[2], 10);
451                         var resline  = parseInt(match[3], 10);
452                         var numlines = parseInt(match[4], 10);
453
454                         var c = commits[sha1];
455                         if (!c) {
456                                 c = new Commit(sha1);
457                                 commits[sha1] = c;
458                         }
459                         curCommit = c;
460
461                         curGroup.srcline = srcline;
462                         curGroup.resline = resline;
463                         curGroup.numlines = numlines;
464
465                 } else if ((match = infoRe.exec(lines[i]))) {
466                         var info = match[1];
467                         var data = match[2];
468                         switch (info) {
469                         case 'filename':
470                                 curCommit.filename = unquote(data);
471                                 // 'filename' information terminates the entry
472                                 handleLine(curCommit, curGroup);
473                                 updateProgressInfo();
474                                 break;
475                         case 'author':
476                                 curCommit.author = data;
477                                 break;
478                         case 'author-time':
479                                 curCommit.authorTime = parseInt(data, 10);
480                                 break;
481                         case 'author-tz':
482                                 curCommit.authorTimezone = data;
483                                 break;
484                         case 'previous':
485                                 curCommit.nprevious++;
486                                 // store only first 'previous' header
487                                 if (!'previous' in curCommit) {
488                                         var parts = data.split(' ', 2);
489                                         curCommit.previous    = parts[0];
490                                         curCommit.file_parent = unquote(parts[1]);
491                                 }
492                                 break;
493                         case 'boundary':
494                                 curCommit.boundary = true;
495                                 break;
496                         } // end switch
497
498                 } else if ((match = endRe.exec(lines[i]))) {
499                         t_interval_server = match[1];
500                         cmds_server = match[2];
501
502                 } else if (lines[i] !== '') {
503                         // malformed line
504
505                 } // end if (match)
506
507         } // end for (lines)
508 }
509
510 /**
511  * Process new data and return pointer to end of processed part
512  *
513  * @param {String} unprocessed: new data (from nextReadPos)
514  * @param {Number} nextReadPos: end of last processed data
515  * @return {Number} end of processed data (new value for nextReadPos)
516  */
517 function processData(unprocessed, nextReadPos) {
518         var lastLineEnd = unprocessed.lastIndexOf('\n');
519         if (lastLineEnd !== -1) {
520                 var lines = unprocessed.substring(0, lastLineEnd).split('\n');
521                 nextReadPos += lastLineEnd + 1 /* 1 == '\n'.length */;
522
523                 processBlameLines(lines);
524         } // end if
525
526         return nextReadPos;
527 }
528
529 /**
530  * Handle XMLHttpRequest errors
531  *
532  * @param {XMLHttpRequest} xhr: XMLHttpRequest object
533  * @param {Number} [xhr.pollTimer] ID of the timeout to clear
534  *
535  * @globals commits
536  */
537 function handleError(xhr) {
538         errorInfo('Server error: ' +
539                 xhr.status + ' - ' + (xhr.statusText || 'Error contacting server'));
540
541         if (typeof xhr.pollTimer === "number") {
542                 clearTimeout(xhr.pollTimer);
543                 delete xhr.pollTimer;
544         }
545         commits = {}; // free memory
546 }
547
548 /**
549  * Called after XMLHttpRequest finishes (loads)
550  *
551  * @param {XMLHttpRequest} xhr: XMLHttpRequest object
552  * @param {Number} [xhr.pollTimer] ID of the timeout to clear
553  *
554  * @globals commits
555  */
556 function responseLoaded(xhr) {
557         if (typeof xhr.pollTimer === "number") {
558                 clearTimeout(xhr.pollTimer);
559                 delete xhr.pollTimer;
560         }
561
562         fixColorsAndGroups();
563         writeTimeInterval();
564         commits = {}; // free memory
565 }
566
567 /**
568  * handler for XMLHttpRequest onreadystatechange event
569  * @see startBlame
570  *
571  * @param {XMLHttpRequest} xhr: XMLHttpRequest object
572  * @param {Number} xhr.prevDataLength: previous value of xhr.responseText.length
573  * @param {Number} xhr.nextReadPos: start of unread part of xhr.responseText
574  * @param {Number} [xhr.pollTimer] ID of the timeout (to reset or cancel)
575  * @param {Boolean} fromTimer: if handler was called from timer
576  */
577 function handleResponse(xhr, fromTimer) {
578
579         /*
580          * xhr.readyState
581          *
582          *  Value  Constant (W3C)    Description
583          *  -------------------------------------------------------------------
584          *  0      UNSENT            open() has not been called yet.
585          *  1      OPENED            send() has not been called yet.
586          *  2      HEADERS_RECEIVED  send() has been called, and headers
587          *                           and status are available.
588          *  3      LOADING           Downloading; responseText holds partial data.
589          *  4      DONE              The operation is complete.
590          */
591
592         if (xhr.readyState !== 4 && xhr.readyState !== 3) {
593                 return;
594         }
595
596         // the server returned error
597         // try ... catch block is to work around bug in IE8
598         try {
599                 if (xhr.readyState === 3 && xhr.status !== 200) {
600                         return;
601                 }
602         } catch (e) {
603                 return;
604         }
605         if (xhr.readyState === 4 && xhr.status !== 200) {
606                 handleError(xhr);
607                 return;
608         }
609
610         // In konqueror xhr.responseText is sometimes null here...
611         if (xhr.responseText === null) {
612                 return;
613         }
614
615
616         // extract new whole (complete) lines, and process them
617         if (xhr.prevDataLength !== xhr.responseText.length) {
618                 xhr.prevDataLength = xhr.responseText.length;
619                 var unprocessed = xhr.responseText.substring(xhr.nextReadPos);
620                 xhr.nextReadPos = processData(unprocessed, xhr.nextReadPos);
621         }
622
623         // did we finish work?
624         if (xhr.readyState === 4) {
625                 responseLoaded(xhr);
626                 return;
627         }
628
629         // if we get from timer, we have to restart it
630         // otherwise onreadystatechange gives us partial response, timer not needed
631         if (fromTimer) {
632                 setTimeout(function () {
633                         handleResponse(xhr, true);
634                 }, 1000);
635
636         } else if (typeof xhr.pollTimer === "number") {
637                 clearTimeout(xhr.pollTimer);
638                 delete xhr.pollTimer;
639         }
640 }
641
642 // ============================================================
643 // ------------------------------------------------------------
644
645 /**
646  * Incrementally update line data in blame_incremental view in gitweb.
647  *
648  * @param {String} blamedataUrl: URL to server script generating blame data.
649  * @param {String} bUrl: partial URL to project, used to generate links.
650  *
651  * Called from 'blame_incremental' view after loading table with
652  * file contents, a base for blame view.
653  *
654  * @globals t0, projectUrl, div_progress_bar, totalLines
655 */
656 function startBlame(blamedataUrl, bUrl) {
657
658         var xhr = createRequestObject();
659         if (!xhr) {
660                 errorInfo('ERROR: XMLHttpRequest not supported');
661                 return;
662         }
663
664         t0 = new Date();
665         projectUrl = bUrl + (bUrl.indexOf('?') === -1 ? '?' : ';');
666         if ((div_progress_bar = document.getElementById('progress_bar'))) {
667                 //div_progress_bar.setAttribute('style', 'width: 100%;');
668                 div_progress_bar.style.cssText = 'width: 100%;';
669         }
670         totalLines = countLines();
671         updateProgressInfo();
672
673         /* add extra properties to xhr object to help processing response */
674         xhr.prevDataLength = -1;  // used to detect if we have new data
675         xhr.nextReadPos = 0;      // where unread part of response starts
676
677         xhr.onreadystatechange = function () {
678                 handleResponse(xhr, false);
679         };
680
681         xhr.open('GET', blamedataUrl);
682         xhr.setRequestHeader('Accept', 'text/plain');
683         xhr.send(null);
684
685         // not all browsers call onreadystatechange event on each server flush
686         // poll response using timer every second to handle this issue
687         xhr.pollTimer = setTimeout(function () {
688                 handleResponse(xhr, true);
689         }, 1000);
690 }
691
692 /* end of blame_incremental.js */