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