2011-01-14 Ojan Vafai <ojan@chromium.org>
[WebKit.git] / Websites / bugs.webkit.org / code-review.js
1 // Copyright (C) 2010 Adam Barth. All rights reserved.
2 //
3 // Redistribution and use in source and binary forms, with or without
4 // modification, are permitted provided that the following conditions are met:
5 //
6 // 1. Redistributions of source code must retain the above copyright notice,
7 // this list of conditions and the following disclaimer.
8 //
9 // 2. Redistributions in binary form must reproduce the above copyright notice,
10 // this list of conditions and the following disclaimer in the documentation
11 // and/or other materials provided with the distribution.
12 //
13 // THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND ANY
14 // EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15 // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16 // DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
17 // ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18 // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19 // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
20 // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
21 // LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
22 // OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
23 // DAMAGE.
24
25 (function() {
26   /**
27    * Create a new function with some of its arguements
28    * pre-filled.
29    * Taken from goog.partial in the Closure library.
30    * @param {Function} fn A function to partially apply.
31    * @param {...*} var_args Additional arguments that are partially
32    *     applied to fn.
33    * @return {!Function} A partially-applied form of the function.
34    */
35   function partial(fn, var_args) {
36     var args = Array.prototype.slice.call(arguments, 1);
37     return function() {
38       // Prepend the bound arguments to the current arguments.
39       var newArgs = Array.prototype.slice.call(arguments);
40       newArgs.unshift.apply(newArgs, args);
41       return fn.apply(this, newArgs);
42     };
43   };
44
45   function determineAttachmentID() {
46     try {
47       return /id=(\d+)/.exec(window.location.search)[1]
48     } catch (ex) {
49       return;
50     }
51   }
52
53   // Attempt to activate only in the "Review Patch" context.
54   if (window.top != window)
55     return;
56
57   if (!window.location.search.match(/action=review/)
58       && !window.location.toString().match(/bugs\.webkit\.org\/PrettyPatch/))
59     return;
60
61   var attachment_id = determineAttachmentID();
62   if (!attachment_id)
63     console.log('No attachment ID');
64
65   var next_line_id = 0;
66   var files = {};
67   var original_file_contents = {};
68   var patched_file_contents = {};
69   var WEBKIT_BASE_DIR = "http://svn.webkit.org/repository/webkit/trunk/";
70   var SIDE_BY_SIDE_DIFFS_KEY = 'sidebysidediffs';
71
72   function idForLine(number) {
73     return 'line' + number;
74   }
75
76   function nextLineID() {
77     return idForLine(next_line_id++);
78   }
79
80   function forEachLine(callback) {
81     for (var i = 0; i < next_line_id; ++i) {
82       callback($('#' + idForLine(i)));
83     }
84   }
85
86   function idify() {
87     this.id = nextLineID();
88   }
89
90   function hoverify() {
91     $(this).hover(function() {
92       $(this).addClass('hot');
93     },
94     function () {
95       $(this).removeClass('hot');
96     });
97   }
98
99   function fileDiffFor(line) {
100     return line.parents('.FileDiff');
101   }
102
103   function activeCommentFor(line) {
104     // Scope to the diffSection as a performance improvement.
105     return $('textarea[data-comment-for~="' + line[0].id + '"]', fileDiffFor(line));
106   }
107
108   function previousCommentsFor(line) {
109     // Scope to the diffSection as a performance improvement.
110     return $('div[data-comment-for~="' + line[0].id + '"].previousComment', fileDiffFor(line));
111   }
112
113   function findCommentPositionFor(line) {
114     var previous_comments = previousCommentsFor(line);
115     var num_previous_comments = previous_comments.size();
116     if (num_previous_comments)
117       return $(previous_comments[num_previous_comments - 1])
118     return line;
119   }
120
121   function findCommentBlockFor(line) {
122     var comment_block = findCommentPositionFor(line).next();
123     if (!comment_block.hasClass('comment'))
124       return;
125     return comment_block;
126   }
127
128   function insertCommentFor(line, block) {
129     findCommentPositionFor(line).after(block);
130   }
131
132   function addCommentFor(line) {
133     if (line.attr('data-has-comment')) {
134       // FIXME: This query is overly complex because we place comment blocks
135       // after Lines.  Instead, comment blocks should be children of Lines.
136       findCommentPositionFor(line).next().next().filter('.frozenComment').each(unfreezeComment);
137       return;
138     }
139     line.attr('data-has-comment', 'true');
140     line.addClass('commentContext');
141
142     var comment_block = $('<div class="comment"><textarea data-comment-for="' + line.attr('id') + '"></textarea><div class="actions"><button class="ok">OK</button><button class="discard">Discard</button></div></div>');
143     insertCommentFor(line, comment_block);
144     comment_block.hide().slideDown('fast', function() {
145       $(this).children('textarea').focus();
146     });
147   }
148
149   function addCommentField() {
150     var id = $(this).attr('data-comment-for');
151     if (!id)
152       id = this.id;
153     addCommentFor($('#' + id));
154   }
155
156   function addPreviousComment(line, author, comment_text) {
157     var line_id = line.attr('id');
158     var comment_block = $('<div data-comment-for="' + line_id + '" class="previousComment"></div>');
159     var author_block = $('<div class="author"></div>').text(author + ':');
160     var text_block = $('<div class="content"></div>').text(comment_text);
161     comment_block.append(author_block).append(text_block).each(hoverify).click(addCommentField);
162     addDataCommentBaseLine(line, line_id);
163     insertCommentFor(line, comment_block);
164   }
165
166   function displayPreviousComments(comments) {
167     for (var i = 0; i < comments.length; ++i) {
168       var author = comments[i].author;
169       var file_name = comments[i].file_name;
170       var line_number = comments[i].line_number;
171       var comment_text = comments[i].comment_text;
172
173       var file = files[file_name];
174
175       var query = '.Line .to';
176       if (line_number[0] == '-') {
177         // The line_number represent a removal.  We need to adjust the query to
178         // look at the "from" lines.
179         query = '.Line .from';
180         // Trim off the '-' control character.
181         line_number = line_number.substr(1);
182       }
183
184       $(file).find(query).each(function() {
185         if ($(this).text() != line_number)
186           return;
187         var line = $(this).parent();
188         addPreviousComment(line, author, comment_text);
189       });
190     }
191
192     var help_text = 'Scroll though diffs with the "j" and "k" keys.';
193     if (comments.length == 0) {
194       $('#message .commentStatus').text(help_text);
195       return;
196     }
197
198     descriptor = comments.length + ' comment';
199     if (comments.length > 1)
200       descriptor += 's';
201     $('#message .commentStatus').text('This patch has ' + descriptor + '.  Scroll through them with the "n" and "p" keys. ' + help_text);
202   }
203
204   function scanForComments(author, text) {
205     var comments = []
206     var lines = text.split('\n');
207     for (var i = 0; i < lines.length; ++i) {
208       var parts = lines[i].match(/^([> ]+)([^:]+):(-?\d+)$/);
209       if (!parts)
210         continue;
211       var quote_markers = parts[1];
212       var file_name = parts[2];
213       // FIXME: Store multiple lines for multiline comments and correctly import them here.
214       var line_number = parts[3];
215       if (!file_name in files)
216         continue;
217       while (i < lines.length && lines[i].length > 0 && lines[i][0] == '>')
218         ++i;
219       var comment_lines = [];
220       while (i < lines.length && (lines[i].length == 0 || lines[i][0] != '>')) {
221         comment_lines.push(lines[i]);
222         ++i;
223       }
224       --i; // Decrement i because the for loop will increment it again in a second.
225       var comment_text = comment_lines.join('\n').trim();
226       comments.push({
227         'author': author,
228         'file_name': file_name,
229         'line_number': line_number,
230         'comment_text': comment_text
231       });
232     }
233     return comments;
234   }
235
236   function isReviewFlag(select) {
237     return $(select).attr('title') == 'Request for patch review.';
238   }
239
240   function isCommitQueueFlag(select) {
241     return $(select).attr('title').match(/commit-queue/);
242   }
243
244   function findControlForFlag(select) {
245     if (isReviewFlag(select))
246       return $('#toolbar .review select');
247     else if (isCommitQueueFlag(select))
248       return $('#toolbar .commitQueue select');
249     return $();
250   }
251
252   function addFlagsForAttachment(details) {
253     var flag_control = "<select><option></option><option>?</option><option>+</option><option>-</option></select>";
254     $('#flagContainer').append(
255       $('<span class="review"> r: ' + flag_control + '</span>')).append(
256       $('<span class="commitQueue"> cq: ' + flag_control + '</span>'));
257
258     details.find('#flags select').each(function() {
259       var requestee = $(this).parent().siblings('td:first-child').text().trim();
260       if (requestee.length) {
261         // Remove trailing ':'.
262         requestee = requestee.substr(0, requestee.length - 1);
263         requestee = ' (' + requestee + ')';
264       }
265       var control = findControlForFlag(this)
266       control.attr('selectedIndex', $(this).attr('selectedIndex'));
267       control.parent().prepend(requestee);
268     });
269   }
270
271   window.addEventListener('message', function(e) {
272     if (e.origin != 'https://webkit-commit-queue.appspot.com')
273       return;
274
275     if (e.data.height) {
276       $('.statusBubble')[0].style.height = e.data.height;
277       $('.statusBubble')[0].style.width = e.data.width;
278     }
279   }, false);
280
281   function handleStatusBubbleLoad(e) {
282     e.target.contentWindow.postMessage('containerMetrics', 'https://webkit-commit-queue.appspot.com');
283   }
284
285   function fetchHistory() {
286     $.get('attachment.cgi?id=' + attachment_id + '&action=edit', function(data) {
287       var bug_id = /Attachment \d+ Details for Bug (\d+)/.exec(data)[1];
288       $.get('show_bug.cgi?id=' + bug_id, function(data) {
289         var comments = [];
290         $(data).find('.bz_comment').each(function() {
291           var author = $(this).find('.email').text();
292           var text = $(this).find('.bz_comment_text').text();
293           var comment_marker = '(From update of attachment ' + attachment_id + ' .details.)';
294           if (text.match(comment_marker))
295             $.merge(comments, scanForComments(author, text));
296         });
297         displayPreviousComments(comments);
298       });
299
300       var details = $(data);
301       addFlagsForAttachment(details);
302
303       var statusBubble = document.createElement('iframe');
304       statusBubble.className = 'statusBubble';
305       statusBubble.src  = 'https://webkit-commit-queue.appspot.com/status-bubble/' + attachment_id;
306       statusBubble.scrolling = 'no';
307       // Can't append the HTML because we need to set the onload handler before appending the iframe to the DOM.
308       statusBubble.onload = handleStatusBubbleLoad;
309       $('#statusBubbleContainer').append(statusBubble);
310
311       $('#toolbar .bugLink').html('<a href="/show_bug.cgi?id=' + bug_id + '" target="_blank">Bug ' + bug_id + '</a>');
312     });
313   }
314
315   function crawlDiff() {
316     $('.Line').each(idify).each(hoverify);
317     $('.FileDiff').each(function() {
318       var file_name = $(this).children('h1').text();
319       files[file_name] = this;
320       addExpandLinks(file_name);
321       $('h1', this).after('<div class="FileDiffLinkContainer">' + diffLinksHtml() + '</div>');
322       updateDiffLinkVisibility(this);
323     });
324   }
325
326   function addExpandLinks(file_name) {
327     if (file_name.indexOf('ChangeLog') != -1)
328       return;
329
330     var file_diff = files[file_name];
331
332     // Don't show the links to expand upwards/downwards if the patch starts/ends without context
333     // lines, i.e. starts/ends with add/remove lines.
334     var first_line = file_diff.querySelector('.LineContainer');
335
336     // If there is no element with a "Line" class, then this is an image diff.
337     if (!first_line)
338       return;
339
340     $('.context', file_diff).detach();
341
342     var expand_bar_index = 0;
343     if (!$(first_line).hasClass('add') && !$(first_line).hasClass('remove'))
344       $('h1', file_diff).after(expandBarHtml(file_name, BELOW))
345
346     $('br').replaceWith(expandBarHtml(file_name));
347
348     var last_line = file_diff.querySelector('.LineContainer:last-of-type');
349     // Some patches for new files somehow end up with an empty context line at the end
350     // with a from line number of 0. Don't show expand links in that case either.
351     if (!$(last_line).hasClass('add') && !$(last_line).hasClass('remove') && fromLineNumber(last_line) != 0)
352       $(file_diff).append(expandBarHtml(file_name, ABOVE));
353   }
354
355   function expandBarHtml(file_name, opt_direction) {
356     var html = '<div class="ExpandBar">' +
357         '<div class="ExpandArea Expand' + ABOVE + '"></div>' +
358         '<div class="ExpandLinkContainer LinkContainer"><span class="ExpandText">expand: </span>';
359
360     // FIXME: If there are <100 line to expand, don't show the expand-100 link.
361     // If there are <20 lines to expand, don't show the expand-20 link.
362     if (!opt_direction || opt_direction == ABOVE) {
363       html += expandLinkHtml(ABOVE, 100) +
364           expandLinkHtml(ABOVE, 20);
365     }
366
367     html += expandLinkHtml(ALL);
368
369     if (!opt_direction || opt_direction == BELOW) {
370       html += expandLinkHtml(BELOW, 20) +
371         expandLinkHtml(BELOW, 100);
372     }
373
374     html += '</div><div class="ExpandArea Expand' + BELOW + '"></div></div>';
375     return html;
376   }
377
378   function expandLinkHtml(direction, amount) {
379     return "<a class='ExpandLink' href='javascript:' data-direction='" + direction + "' data-amount='" + amount + "'>" +
380         (amount ? amount + " " : "") + direction + "</a>";
381   }
382
383   function handleExpandLinkClick() {
384     var expand_bar = $(this).parents('.ExpandBar');
385     var file_name = expand_bar.parents('.FileDiff').children('h1')[0].textContent;
386     var expand_function = partial(expand, expand_bar[0], file_name, this.getAttribute('data-direction'), Number(this.getAttribute('data-amount')));
387     if (file_name in original_file_contents)
388       expand_function();
389     else
390       getWebKitSourceFile(file_name, expand_function, expand_bar);
391   }
392
393   function handleSideBySideLinkClick() {
394     convertDiff('sidebyside', this);
395   }
396
397   function handleUnifyLinkClick() {
398     convertDiff('unified', this);
399   }
400
401   function convertDiff(difftype, convert_link) {
402     var file_diffs = $(convert_link).parents('.FileDiff');
403     if (!file_diffs.size()) {
404       localStorage.setItem('code-review-diffstate', difftype);
405       file_diffs = $('.FileDiff');
406     }
407
408     convertAllFileDiffs(difftype, file_diffs);
409   }
410
411   function getWebKitSourceFile(file_name, onLoad, expand_bar) {
412     function handleLoad(contents) {
413       original_file_contents[file_name] = contents.split('\n');
414       patched_file_contents[file_name] = applyDiff(original_file_contents[file_name], file_name);
415       onLoad();
416     };
417
418     $.ajax({
419       url: WEBKIT_BASE_DIR + file_name,
420       context: document.body,
421       complete: function(xhr, data) {
422               if (xhr.status == 0)
423                   handleLoadError(expand_bar);
424               else
425                   handleLoad(xhr.responseText);
426       }
427     });
428   }
429
430   function replaceExpandLinkContainers(expand_bar, text) {
431     $('.ExpandLinkContainer', $(expand_bar).parents('.FileDiff')).replaceWith('<span class="ExpandText">' + text + '</span>');
432   }
433
434   function handleLoadError(expand_bar) {
435     // FIXME: In this case, try fetching the source file at the revision the patch was created at,
436     // in case the file has bee deleted.
437     // Might need to modify webkit-patch to include that data in the diff.
438     replaceExpandLinkContainers(expand_bar, "Can't expand. Is this a new or deleted file?");
439   }
440
441   var ABOVE = 'above';
442   var BELOW = 'below';
443   var ALL = 'all';
444
445   function expand(expand_bar, file_name, direction, amount) {
446     if (file_name in original_file_contents && !patched_file_contents[file_name]) {
447       // FIXME: In this case, try fetching the source file at the revision the patch was created at.
448       // Might need to modify webkit-patch to include that data in the diff.
449       replaceExpandLinkContainers(expand_bar, "Can't expand. Unable to apply patch to tip of tree.");
450       return;
451     }
452
453     var above_expansion = expand_bar.querySelector('.Expand' + ABOVE)
454     var below_expansion = expand_bar.querySelector('.Expand' + BELOW)
455
456     var above_last_line = above_expansion.querySelector('.ExpansionLine:last-of-type');
457     if (!above_last_line) {
458       var diff_section = expand_bar.previousElementSibling;
459       above_last_line = diff_section.querySelector('.LineContainer:last-of-type');
460     }
461
462     var above_last_line_num, above_last_from_line_num;
463     if (above_last_line) {
464       above_last_line_num = toLineNumber(above_last_line);
465       above_last_from_line_num = fromLineNumber(above_last_line);
466     } else
467       above_last_from_line_num = above_last_line_num = 0;
468
469     var below_first_line = below_expansion.querySelector('.ExpansionLine');
470     if (!below_first_line) {
471       var diff_section = expand_bar.nextElementSibling;
472       if (diff_section)
473         below_first_line = diff_section.querySelector('.LineContainer');
474     }
475
476     var below_first_line_num, below_first_from_line_num;
477     if (below_first_line) {
478       below_first_line_num = toLineNumber(below_first_line) - 1;
479       below_first_from_line_num = fromLineNumber(below_first_line) - 1;
480     } else
481       below_first_from_line_num = below_first_line_num = patched_file_contents[file_name].length - 1;
482
483     var start_line_num, start_from_line_num;
484     var end_line_num;
485
486     if (direction == ABOVE) {
487       start_from_line_num = above_last_from_line_num;
488       start_line_num = above_last_line_num;
489       end_line_num = Math.min(start_line_num + amount, below_first_line_num);
490     } else if (direction == BELOW) {
491       end_line_num = below_first_line_num;
492       start_line_num = Math.max(end_line_num - amount, above_last_line_num)
493       start_from_line_num = Math.max(below_first_from_line_num - amount, above_last_from_line_num)
494     } else { // direction == ALL
495       start_line_num = above_last_line_num;
496       start_from_line_num = above_last_from_line_num;
497       end_line_num = below_first_line_num;
498     }
499
500     var expansion_area;
501     // Filling in all the remaining lines. Overwrite the expand links.
502     if (start_line_num == above_last_line_num && end_line_num == below_first_line_num) {
503       expansion_area = expand_bar.querySelector('.ExpandLinkContainer');
504       expansion_area.innerHTML = '';
505     } else {
506       expansion_area = direction == ABOVE ? above_expansion : below_expansion;
507     }
508
509     insertLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num);
510   }
511
512   function unifiedLine(from, to, contents, is_expansion_line, opt_className, opt_attributes) {
513     var className = is_expansion_line ? 'ExpansionLine' : 'LineContainer Line';
514     if (opt_className)
515       className += ' ' + opt_className;
516
517     var lineNumberClassName = is_expansion_line ? 'expansionLineNumber' : 'lineNumber';
518
519     var line = $('<div class="' + className + '" ' + (opt_attributes || '') + '>' +
520         '<span class="from ' + lineNumberClassName + '">' + (from || '&nbsp;') +
521         '</span><span class="to ' + lineNumberClassName + '">' + (to || '&nbsp;') +
522         '</span> <span class="text"></span>' +
523         '</div>');
524
525     $('.text', line).replaceWith(contents);
526     return line;
527   }
528
529   function unifiedExpansionLine(line_number, contents) {
530     return unifiedLine(line_number, line_number, contents, true);
531   }
532
533   function sideBySideExpansionLine(line_number, contents) {
534     var line = $('<div class="ExpansionLine"></div>');
535     // Clone the contents so we have two copies we can put back in the DOM.
536     line.append(lineSide('from', contents.clone(true), true, line_number));
537     line.append(lineSide('to', contents, true, line_number));
538     return line;
539   }
540
541   function lineSide(side, contents, is_expansion_line, opt_line_number, opt_attributes, opt_class) {
542     var class_name = '';
543     if (opt_attributes || opt_class) {
544       class_name = 'class="';
545       if (opt_attributes)
546         class_name += is_expansion_line ? 'ExpansionLine' : 'Line';
547       class_name += ' ' + (opt_class || '') + '"';
548     }
549
550     var attributes = opt_attributes || '';
551
552     var line_side = $('<div class="LineSide">' +
553         '<div ' + attributes + ' ' + class_name + '>' +
554           '<span class="' + side + ' ' + (is_expansion_line ? 'expansionLineNumber' : 'lineNumber') + '">' +
555               (opt_line_number || '&nbsp;') +
556           '</span>' +
557           '<span class="text"></span>' +
558         '</div>' +
559         '</div>');
560
561     $('.text', line_side).replaceWith(contents);
562     return line_side;
563   }
564
565   function insertLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num) {
566     var fragment = document.createDocumentFragment();
567     var is_side_by_side = isDiffSideBySide(files[file_name]);
568
569     for (var i = 0; i < end_line_num - start_line_num; i++) {
570       // FIXME: from line numbers are wrong
571       var line_number = start_from_line_num + i + 1;
572       var contents = $('<span class="text"></span>');
573       contents.text(patched_file_contents[file_name][start_line_num + i]);
574       var line = is_side_by_side ? sideBySideExpansionLine(line_number, contents) : unifiedExpansionLine(line_number, contents);
575       fragment.appendChild(line[0]);
576     }
577
578     if (direction == BELOW)
579       expansion_area.insertBefore(fragment, expansion_area.firstChild);
580     else
581       expansion_area.appendChild(fragment);
582   }
583
584   function hunkStartingLine(patched_file, context, prev_line, hunk_num) {
585     var PATCH_FUZZ = 2;
586     var current_line = -1;
587     var last_context_line = context[context.length - 1];
588     if (patched_file[prev_line] == last_context_line)
589       current_line = prev_line + 1;
590     else {
591       for (var i = prev_line - PATCH_FUZZ; i < prev_line + PATCH_FUZZ; i++) {
592         if (patched_file[i] == last_context_line)
593           current_line = i + 1;
594       }
595
596       if (current_line == -1) {
597         console.log('Hunk #' + hunk_num + ' FAILED.');
598         return -1;
599       }
600     }
601
602     // For paranoia sake, confirm the rest of the context matches;
603     for (var i = 0; i < context.length - 1; i++) {
604       if (patched_file[current_line - context.length + i] != context[i]) {
605         console.log('Hunk #' + hunk_num + ' FAILED. Did not match preceding context.');
606         return -1;
607       }
608     }
609
610     return current_line;
611   }
612
613   function fromLineNumber(line) {
614     var node = line.querySelector('.from');
615     return node ? Number(node.textContent) : 0;
616   }
617
618   function toLineNumber(line) {
619     var node = line.querySelector('.to');
620     return node ? Number(node.textContent) : 0;
621   }
622
623   function textContentsFor(line) {
624     // Just get the first match since a side-by-side diff has two lines with text inside them for
625     // unmodified lines in the diff.
626     return $('.text', line).first().text();
627   }
628
629   function lineNumberForFirstNonContextLine(patched_file, line, prev_line, context, hunk_num) {
630     if (context.length) {
631       var prev_line_num = fromLineNumber(prev_line) - 1;
632       return hunkStartingLine(patched_file, context, prev_line_num, hunk_num);
633     }
634
635     if (toLineNumber(line) == 1 || fromLineNumber(line) == 1)
636       return 0;
637
638     console.log('Failed to apply patch. Adds or removes lines before any context lines.');
639     return -1;
640   }
641
642   function applyDiff(original_file, file_name) {
643     var diff_sections = files[file_name].getElementsByClassName('DiffSection');
644     var patched_file = original_file.concat([]);
645
646     // Apply diffs in reverse order to avoid needing to keep track of changing line numbers.
647     for (var i = diff_sections.length - 1; i >= 0; i--) {
648       var section = diff_sections[i];
649       var lines = section.getElementsByClassName('Line');
650       var current_line = -1;
651       var context = [];
652       var hunk_num = i + 1;
653
654       for (var j = 0, lines_len = lines.length; j < lines_len; j++) {
655         var line = lines[j];
656         var line_contents = textContentsFor(line);
657         if ($(line).hasClass('add')) {
658           if (current_line == -1) {
659             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
660             if (current_line == -1)
661               return null;
662           }
663
664           patched_file.splice(current_line, 0, line_contents);
665           current_line++;
666         } else if ($(line).hasClass('remove')) {
667           if (current_line == -1) {
668             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
669             if (current_line == -1)
670               return null;
671           }
672
673           if (patched_file[current_line] != line_contents) {
674             console.log('Hunk #' + hunk_num + ' FAILED.');
675             return null;
676           }
677
678           patched_file.splice(current_line, 1);
679         } else if (current_line == -1) {
680           context.push(line_contents);
681         } else if (line_contents != patched_file[current_line]) {
682           console.log('Hunk #' + hunk_num + ' FAILED. Context at end did not match');
683           return null;
684         } else {
685           current_line++;
686         }
687       }
688     }
689
690     return patched_file;
691   }
692
693   function openOverallComments(e) {
694     $('.overallComments textarea').addClass('open');
695     $('#statusBubbleContainer').addClass('wrap');
696   }
697
698   function onBodyResize() {
699     updateToolbarAnchorState();
700   }
701
702   function updateToolbarAnchorState() {
703     var has_scrollbar = window.innerWidth > document.documentElement.offsetWidth;
704     $('#toolbar').toggleClass('anchored', has_scrollbar);
705   }
706
707   function diffLinksHtml(opt_containerClassName) {
708     var containerClassName = opt_containerClassName || '';
709     return '<div class="DiffLinks ' + containerClassName + '">' +
710       '<a href="javascript:" class="unify-link">unified</a>' +
711       '<a href="javascript:" class="side-by-side-link">side-by-side</a>' +
712     '</div>';
713   }
714
715   $(document).ready(function() {
716     crawlDiff();
717     fetchHistory();
718     $(document.body).prepend('<div id="message">' +
719         '<div class="help">Select line numbers to add a comment.' +
720           diffLinksHtml('LinkContainer') +
721         '</div>' +
722         '<div class="commentStatus"></div>' +
723         '</div>');
724     $(document.body).append('<div id="toolbar">' +
725         '<div class="overallComments">' +
726             '<textarea placeholder="Overall comments"></textarea>' +
727         '</div>' +
728         '<div>' +
729           '<span id="statusBubbleContainer"></span>' +
730           '<span class="actions">' +
731               '<span class="links"><span class="bugLink"></span></span>' +
732               '<span id="flagContainer"></span>' +
733               '<button id="preview_comments">Preview</button>' +
734               '<button id="post_comments">Publish</button> ' +
735           '</span></div>' +
736         '</div>' +
737         '</div>');
738
739     $('.overallComments textarea').bind('click', openOverallComments);
740
741     $(document.body).prepend('<div id="comment_form" class="inactive"><div class="winter"></div><div class="lightbox"><iframe id="reviewform" src="attachment.cgi?id=' + attachment_id + '&action=reviewform"></iframe></div></div>');
742
743     // Create a dummy iframe and monitor resizes in it's contentWindow to detect when the top document's body changes size.
744     // FIXME: Should we setTimeout throttle these?
745     var resize_iframe = $('<iframe class="pseudo_resize_event_iframe"></iframe>');
746     $(document.body).append(resize_iframe);
747     $(resize_iframe[0].contentWindow).bind('resize', onBodyResize);
748
749     updateToolbarAnchorState();
750     loadDiffState();
751   });
752
753   function loadDiffState() {
754     var diffstate = localStorage.getItem('code-review-diffstate');
755     if (diffstate != 'sidebyside' && diffstate != 'unified')
756       return;
757
758     convertAllFileDiffs(diffstate, $('.FileDiff'));
759   }
760
761   function isDiffSideBySide(file_diff) {
762     return diffState(file_diff) == 'sidebyside';
763   }
764
765   function diffState(file_diff) {
766     var diff_state = $(file_diff).attr('data-diffstate');
767     return diff_state || 'unified';
768   }
769
770   function unifyLine(line, from, to, contents, classNames, attributes, id) {
771     var new_line = unifiedLine(from, to, contents, false, classNames, attributes);
772     var old_line = $(line);
773     if (!old_line.hasClass('LineContainer'))
774       old_line = old_line.parents('.LineContainer');
775
776     var comments = commentsToTransferFor($(document.getElementById(id)));
777     old_line.after(comments);
778     old_line.replaceWith(new_line);
779   }
780
781   function updateDiffLinkVisibility(file_diff) {
782     if (diffState(file_diff) == 'unified') {
783       $('.side-by-side-link', file_diff).show();
784       $('.unify-link', file_diff).hide();
785     } else {
786       $('.side-by-side-link', file_diff).hide();
787       $('.unify-link', file_diff).show();
788     }
789   }
790
791   function convertAllFileDiffs(diff_type, file_diffs) {
792     file_diffs.each(function() {
793       convertFileDiff(diff_type, this);
794     });
795   }
796
797   function convertFileDiff(diff_type, file_diff) {
798     if (diffState(file_diff) == diff_type)
799       return;
800
801     $(file_diff).attr('data-diffstate', diff_type);
802     updateDiffLinkVisibility(file_diff);
803
804     $('.Line', file_diff).each(function() {
805       convertLine(diff_type, this);
806     });
807
808     $('.ExpansionLine', file_diff).each(function() {
809       convertExpansionLine(diff_type, this);
810     });
811   }
812
813   function convertLine(diff_type, line) {
814     var convert_function = diff_type == 'sidebyside' ? sideBySideifyLine : unifyLine;
815     var from = fromLineNumber(line);
816     var to = toLineNumber(line);
817     var contents = $('.text', line).first();
818     var classNames = classNamesForMovingLine(line);
819     var attributes = attributesForMovingLine(line);
820     var id = line.id;
821     convert_function(line, from, to, contents, classNames, attributes, id)
822   }
823
824   function classNamesForMovingLine(line) {
825     var classParts = line.className.split(' ');
826     var classBuffer = [];
827     for (var i = 0; i < classParts.length; i++) {
828       var part = classParts[i];
829       if (part != 'LineContainer' && part != 'Line')
830         classBuffer.push(part);
831     }
832     return classBuffer.join(' ');
833   }
834
835   function attributesForMovingLine(line) {
836     var attributesBuffer = ['id=' + line.id];
837     // Make sure to keep all data- attributes.
838     $(line.attributes).each(function() {
839       if (this.name.indexOf('data-') == 0)
840         attributesBuffer.push(this.name + '=' + this.value);
841     });
842     return attributesBuffer.join(' ');
843   }
844
845   // FIXME: Put removed lines to the left of their corresponding added lines.
846   function sideBySideifyLine(line, from, to, contents, classNames, attributes, id) {
847     var from_class = '';
848     var to_class = '';
849     var from_attributes = '';
850     var to_attributes = '';
851     // Clone the contents so we have two copies we can put back in the DOM.
852     var from_contents = contents.clone(true);
853     var to_contents = contents;
854
855     var container_class = 'LineContainer';
856     var container_attributes = '';
857
858     if (from && !to) { // This is a remove line.
859       from_class = classNames;
860       from_attributes = attributes;
861       to_contents = '';
862     } else if (to && !from) { // This is an add line.
863       to_class = classNames;
864       to_attributes = attributes;
865       from_contents = '';
866     } else {
867       container_attributes = attributes;
868       container_class += ' Line ' + classNames;
869     }
870
871     var new_line = $('<div ' + container_attributes + ' class="' + container_class + '"></div>');
872     new_line.append(lineSide('from', from_contents, false, from, from_attributes, from_class));
873     new_line.append(lineSide('to', to_contents, false, to, to_attributes, to_class));
874
875     $(line).replaceWith(new_line);
876
877     var line = $(document.getElementById(id));
878     line.after(commentsToTransferFor(line));
879   }
880
881   function convertExpansionLine(diff_type, line) {
882     var convert_function = diff_type == 'sidebyside' ? sideBySideExpansionLine : unifiedExpansionLine;
883     var contents = $('.text', line).first();
884     var line_number = fromLineNumber(line);
885     var new_line = convert_function(line_number, contents);
886     $(line).replaceWith(new_line);
887   }
888
889   function commentsToTransferFor(line) {
890     var fragment = document.createDocumentFragment();
891
892     previousCommentsFor(line).each(function() {
893       fragment.appendChild(this);
894     });
895
896     var active_comments = activeCommentFor(line);
897     var num_active_comments = active_comments.size();
898     if (num_active_comments > 0) {
899       if (num_active_comments > 1)
900         console.log('ERROR: There is more than one active comment for ' + line.attr('id') + '.');
901
902       var parent = active_comments[0].parentNode;
903       var frozenComment = parent.nextSibling;
904       fragment.appendChild(parent);
905       fragment.appendChild(frozenComment);
906     }
907
908     return fragment;
909   }
910
911   function discardComment() {
912     var line_id = $(this).parentsUntil('.comment').parent().find('textarea').attr('data-comment-for');
913     var line = $('#' + line_id)
914     findCommentBlockFor(line).slideUp('fast', function() {
915       $(this).remove();
916       line.removeAttr('data-has-comment');
917       trimCommentContextToBefore(line, line.attr('data-comment-base-line'));
918     });
919   }
920
921   function unfreezeComment() {
922     $(this).prev().show();
923     $(this).remove();
924   }
925
926   $('.side-by-side-link').live('click', handleSideBySideLinkClick);
927   $('.unify-link').live('click', handleUnifyLinkClick);
928   $('.ExpandLink').live('click', handleExpandLinkClick);
929   $('.comment .discard').live('click', discardComment);
930   $('.frozenComment').live('click', unfreezeComment);
931
932   $('.comment .ok').live('click', function() {
933     var comment_textarea = $(this).parentsUntil('.comment').parent().find('textarea');
934     if (comment_textarea.val().trim() == '') {
935       discardComment.call(this);
936       return;
937     }
938     var line_id = comment_textarea.attr('data-comment-for');
939     var line = $('#' + line_id)
940     findCommentBlockFor(line).hide().after($('<div class="frozenComment"></div>').text(comment_textarea.val()));
941   });
942
943   function focusOn(node) {
944     $('.focused').removeClass('focused');
945     if (node.length == 0)
946       return;
947     $(document).scrollTop(node.addClass('focused').position().top - window.innerHeight / 2);
948   }
949
950   function focusNext(className, is_backward) {
951     var focusable_nodes = $('.previousComment,.DiffBlock').filter(function() {
952       return ($(this).hasClass('previousComment') || $('.add,.remove', this).size());
953     });
954
955     var index = focusable_nodes.index($('.focused'));
956     if (index == -1 && is_backward)
957       index = focusable_nodes.length;
958
959     var offset = is_backward ? -1 : 1;
960     var end = is_backward ? -1 : focusable_nodes.size();
961     for (var i = index + offset; i != end; i = i + offset) {
962       var node = $(focusable_nodes[i]);
963       if (node.hasClass(className)) {
964         focusOn(node);
965         return;
966       }
967     }
968   }
969
970   var kCharCodeForN = 'n'.charCodeAt(0);
971   var kCharCodeForP = 'p'.charCodeAt(0);
972   var kCharCodeForJ = 'j'.charCodeAt(0);
973   var kCharCodeForK = 'k'.charCodeAt(0);
974
975   $('body').live('keypress', function() {
976     // FIXME: There's got to be a better way to avoid seeing these keypress
977     // events.
978     if (event.target.nodeName == 'TEXTAREA')
979       return;
980
981     switch (event.charCode) {
982     case kCharCodeForN:
983       focusNext('previousComment', false);
984       break;
985
986     case kCharCodeForP:
987       focusNext('previousComment', true);
988       break;
989
990     case kCharCodeForJ:
991       focusNext('DiffBlock', false);
992       break;
993
994     case kCharCodeForK:
995       focusNext('DiffBlock', true);
996       break;
997     }
998   });
999
1000   function contextLinesFor(comment_base_lines, file_diff) {
1001     var base_lines = comment_base_lines.split(' ');
1002     return $('div[data-comment-base-line]', file_diff).filter(function() {
1003       return $(this).attr('data-comment-base-line').split(' ').some(function(item) {
1004         return base_lines.indexOf(item) != -1;
1005       });
1006     });
1007   }
1008
1009   function numberFrom(line_id) {
1010     return Number(line_id.replace('line', ''));
1011   }
1012
1013   function trimCommentContextToBefore(line, comment_base_line) {
1014     var line_to_trim_to = numberFrom(line.attr('id'));
1015     contextLinesFor(comment_base_line, fileDiffFor(line)).each(function() {
1016       var id = $(this).attr('id');
1017       if (numberFrom(id) > line_to_trim_to)
1018         return;
1019
1020       removeDataCommentBaseLine(this, comment_base_line);
1021       if (!$(this).attr('data-comment-base-line'))
1022         $(this).removeClass('commentContext');
1023     });
1024   }
1025
1026   var in_drag_select = false;
1027
1028   function stopDragSelect() {
1029     $('.selected').removeClass('selected');
1030     in_drag_select = false;
1031   }
1032
1033   function lineOffsetFrom(line, offset) {
1034     var file_diff = line.parents('.FileDiff');
1035     var all_lines = $('.Line', file_diff);
1036     var index = all_lines.index(line);
1037     return $(all_lines[index + offset]);
1038   }
1039
1040   function previousLineFor(line) {
1041     return lineOffsetFrom(line, -1);
1042   }
1043
1044   function nextLineFor(line) {
1045     return lineOffsetFrom(line, 1);
1046   }
1047
1048   $('.lineNumber').live('click', function() {
1049     var line = lineFromLineDescendant($(this));
1050     if (line.hasClass('commentContext'))
1051       trimCommentContextToBefore(previousLineFor(line), line.attr('data-comment-base-line'));
1052   }).live('mousedown', function() {
1053     in_drag_select = true;
1054     lineFromLineDescendant($(this)).addClass('selected');
1055     event.preventDefault();
1056   });
1057
1058   $('.LineContainer').live('mouseenter', function() {
1059     if (!in_drag_select)
1060       return;
1061
1062     var line = lineFromLineContainer(this);
1063     line.addClass('selected');
1064   }).live('mouseup', function() {
1065     if (!in_drag_select)
1066       return;
1067
1068     var selected = $('.selected');
1069
1070     // Select all the lines between the first and last selected lines
1071     // in case we didn't get mouseenter events for any of them.
1072     var current_index = numberFrom(selected.first().attr('id'));
1073     var last_index = numberFrom(selected.last().attr('id'));
1074     while (current_index != last_index) {
1075       $('#line' + current_index).addClass('selected')
1076       current_index++;
1077     }
1078
1079     selected = $('.selected');
1080     var already_has_comment = selected.last().hasClass('commentContext');
1081     selected.addClass('commentContext');
1082
1083     var comment_base_line;
1084     if (already_has_comment)
1085       comment_base_line = selected.last().attr('data-comment-base-line');
1086     else {
1087       var last = lineFromLineDescendant(selected.last());
1088       addCommentFor($(last));
1089       comment_base_line = last.id;
1090     }
1091
1092     selected.each(function() {
1093       addDataCommentBaseLine(this, comment_base_line);
1094     });
1095   });
1096
1097   function addDataCommentBaseLine(line, id) {
1098     var val = $(line).attr('data-comment-base-line');
1099
1100     var parts = val ? val.split(' ') : [];
1101     for (var i = 0; i < parts.length; i++) {
1102       if (parts[i] == id)
1103         return;
1104     }
1105
1106     parts.push(id);
1107     $(line).attr('data-comment-base-line', parts.join(' '));
1108   }
1109
1110   function removeDataCommentBaseLine(line, comment_base_lines) {
1111     var val = $(line).attr('data-comment-base-line');
1112     if (!val)
1113       return;
1114
1115     var base_lines = comment_base_lines.split(' ');
1116     var parts = val.split(' ');
1117     var newVal = [];
1118     for (var i = 0; i < parts.length; i++) {
1119       if (base_lines.indexOf(parts[i]) == -1)
1120         newVal.push(parts[i]);
1121     }
1122
1123     $(line).attr('data-comment-base-line', newVal.join(' '));
1124   }
1125
1126   function lineFromLineDescendant(descendant) {
1127     return descendant.hasClass('Line') ? descendant : descendant.parents('.Line');
1128   }
1129
1130   function lineFromLineContainer(lineContainer) {
1131     var line = $(lineContainer);
1132     if (!line.hasClass('Line'))
1133       line = $('.Line', line);
1134     return line;
1135   }
1136
1137   $('.DiffSection').live('mouseleave', stopDragSelect).live('mouseup', stopDragSelect);
1138
1139   function contextSnippetFor(line, indent) {
1140     var snippets = []
1141     contextLinesFor(line.attr('id'), fileDiffFor(line)).each(function() {
1142       var action = ' ';
1143       if ($(this).hasClass('add'))
1144         action = '+';
1145       else if ($(this).hasClass('remove'))
1146         action = '-';
1147       snippets.push(indent + action + textContentsFor(this));
1148     });
1149     return snippets.join('\n');
1150   }
1151
1152   function fileNameFor(line) {
1153     return fileDiffFor(line).find('h1').text();
1154   }
1155
1156   function indentFor(depth) {
1157     return (new Array(depth + 1)).join('>') + ' ';
1158   }
1159
1160   function snippetFor(line, indent) {
1161     var file_name = fileNameFor(line);
1162     var line_number = line.hasClass('remove') ? '-' + fromLineNumber(line[0]) : toLineNumber(line[0]);
1163     return indent + file_name + ':' + line_number + '\n' + contextSnippetFor(line, indent);
1164   }
1165
1166   function quotePreviousComments(comments) {
1167     var quoted_comments = [];
1168     var depth = comments.size();
1169     comments.each(function() {
1170       var indent = indentFor(depth--);
1171       var text = $(this).children('.content').text();
1172       quoted_comments.push(indent + '\n' + indent + text.split('\n').join('\n' + indent));
1173     });
1174     return quoted_comments.join('\n');
1175   }
1176
1177   $('#comment_form .winter').live('click', function() {
1178     $('#comment_form').addClass('inactive');
1179   });
1180
1181   function fillInReviewForm() {
1182     var comments_in_context = []
1183     forEachLine(function(line) {
1184       if (line.attr('data-has-comment') != 'true')
1185         return;
1186       var comment = findCommentBlockFor(line).children('textarea').val().trim();
1187       if (comment == '')
1188         return;
1189       var previous_comments = previousCommentsFor(line);
1190       var snippet = snippetFor(line, indentFor(previous_comments.size() + 1));
1191       var quoted_comments = quotePreviousComments(previous_comments);
1192       var comment_with_context = [];
1193       comment_with_context.push(snippet);
1194       if (quoted_comments != '')
1195         comment_with_context.push(quoted_comments);
1196       comment_with_context.push('\n' + comment);
1197       comments_in_context.push(comment_with_context.join('\n'));
1198     });
1199     var comment = $('.overallComments textarea').val().trim();
1200     if (comment != '')
1201       comment += '\n\n';
1202     comment += comments_in_context.join('\n\n');
1203     if (comments_in_context.length > 0)
1204       comment = 'View in context: ' + window.location + '\n\n' + comment;
1205     var review_form = $('#reviewform').contents();
1206     review_form.find('#comment').val(comment);
1207     review_form.find('#flags select').each(function() {
1208       var control = findControlForFlag(this);
1209       if (!control.size())
1210         return;
1211       $(this).attr('selectedIndex', control.attr('selectedIndex'));
1212     });
1213   }
1214
1215   $('#preview_comments').live('click', function() {
1216     fillInReviewForm();
1217     $('#comment_form').removeClass('inactive');
1218   });
1219
1220   $('#post_comments').live('click', function() {
1221     fillInReviewForm();
1222     $('#reviewform').contents().find('form').submit();
1223   });
1224 })();