2011-01-10 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 diffSectionFrom(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 + '"]', diffSectionFrom(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', diffSectionFrom(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     if (comments.length == 0)
193       return;
194     descriptor = comments.length + ' comment';
195     if (comments.length > 1)
196       descriptor += 's';
197     $('#message .commentStatus').text('This patch has ' + descriptor + '.  Scroll through them with the "n" and "p" keys.');
198   }
199
200   function scanForComments(author, text) {
201     var comments = []
202     var lines = text.split('\n');
203     for (var i = 0; i < lines.length; ++i) {
204       var parts = lines[i].match(/^([> ]+)([^:]+):(-?\d+)$/);
205       if (!parts)
206         continue;
207       var quote_markers = parts[1];
208       var file_name = parts[2];
209       // FIXME: Store multiple lines for multiline comments and correctly import them here.
210       var line_number = parts[3];
211       if (!file_name in files)
212         continue;
213       while (i < lines.length && lines[i].length > 0 && lines[i][0] == '>')
214         ++i;
215       var comment_lines = [];
216       while (i < lines.length && (lines[i].length == 0 || lines[i][0] != '>')) {
217         comment_lines.push(lines[i]);
218         ++i;
219       }
220       --i; // Decrement i because the for loop will increment it again in a second.
221       var comment_text = comment_lines.join('\n').trim();
222       comments.push({
223         'author': author,
224         'file_name': file_name,
225         'line_number': line_number,
226         'comment_text': comment_text
227       });
228     }
229     return comments;
230   }
231
232   function isReviewFlag(select) {
233     return $(select).attr('title') == 'Request for patch review.';
234   }
235
236   function isCommitQueueFlag(select) {
237     return $(select).attr('title').match(/commit-queue/);
238   }
239
240   function findControlForFlag(select) {
241     if (isReviewFlag(select))
242       return $('#toolbar .review select');
243     else if (isCommitQueueFlag(select))
244       return $('#toolbar .commitQueue select');
245     return $();
246   }
247
248   function addFlagsForAttachment(details) {
249     var flag_control = "<select><option></option><option>?</option><option>+</option><option>-</option></select>";
250     $('#flagContainer').append(
251       $('<span class="review"> r: ' + flag_control + '</span>')).append(
252       $('<span class="commitQueue"> cq: ' + flag_control + '</span>'));
253
254     details.find('#flags select').each(function() {
255       var requestee = $(this).parent().siblings('td:first-child').text().trim();
256       if (requestee.length) {
257         // Remove trailing ':'.
258         requestee = requestee.substr(0, requestee.length - 1);
259         requestee = ' (' + requestee + ')';
260       }
261       var control = findControlForFlag(this)
262       control.attr('selectedIndex', $(this).attr('selectedIndex'));
263       control.parent().prepend(requestee);
264     });
265   }
266
267   window.addEventListener('message', function(e) {
268     if (e.origin != 'https://webkit-commit-queue.appspot.com')
269       return;
270
271     if (e.data.height) {
272       $('.statusBubble')[0].style.height = e.data.height;
273       $('.statusBubble')[0].style.width = e.data.width;
274     }
275   }, false);
276
277   function handleStatusBubbleLoad(e) {
278     e.target.contentWindow.postMessage('containerMetrics', 'https://webkit-commit-queue.appspot.com');
279   }
280
281   function fetchHistory() {
282     $.get('attachment.cgi?id=' + attachment_id + '&action=edit', function(data) {
283       var bug_id = /Attachment \d+ Details for Bug (\d+)/.exec(data)[1];
284       $.get('show_bug.cgi?id=' + bug_id, function(data) {
285         var comments = [];
286         $(data).find('.bz_comment').each(function() {
287           var author = $(this).find('.email').text();
288           var text = $(this).find('.bz_comment_text').text();
289           var comment_marker = '(From update of attachment ' + attachment_id + ' .details.)';
290           if (text.match(comment_marker))
291             $.merge(comments, scanForComments(author, text));
292         });
293         displayPreviousComments(comments);
294       });
295
296       var details = $(data);
297       addFlagsForAttachment(details);
298
299       var statusBubble = document.createElement('iframe');
300       statusBubble.className = 'statusBubble';
301       statusBubble.src  = 'https://webkit-commit-queue.appspot.com/status-bubble/' + attachment_id;
302       statusBubble.scrolling = 'no';
303       // Can't append the HTML because we need to set the onload handler before appending the iframe to the DOM.
304       statusBubble.onload = handleStatusBubbleLoad;
305       $('#statusBubbleContainer').append(statusBubble);
306
307       $('#toolbar .bugLink').html('<a href="/show_bug.cgi?id=' + bug_id + '" target="_blank">Bug ' + bug_id + '</a>');
308     });
309   }
310
311   function crawlDiff() {
312     $('.Line').each(idify).each(hoverify).each(containerify);
313     $('.FileDiff').each(function() {
314       var file_name = $(this).children('h1').text();
315       files[file_name] = this;
316       addExpandLinks(file_name);
317     });
318   }
319
320   function addExpandLinks(file_name) {
321     if (file_name.indexOf('ChangeLog') != -1)
322       return;
323
324     var file_diff = files[file_name];
325
326     // Don't show the links to expand upwards/downwards if the patch starts/ends without context
327     // lines, i.e. starts/ends with add/remove lines.
328     var first_line = file_diff.querySelector('.LineContainer');
329
330     // If there is no element with a "Line" class, then this is an image diff.
331     if (!first_line)
332       return;
333
334     $('.context', file_diff).detach();
335
336     var expand_bar_index = 0;
337     if (!$(first_line).hasClass('add') && !$(first_line).hasClass('remove'))
338       $('h1', file_diff).after(expandBarHtml(file_name, BELOW))
339
340     $('br').replaceWith(expandBarHtml(file_name));
341
342     var last_line = file_diff.querySelector('.LineContainer:last-of-type');
343     // Some patches for new files somehow end up with an empty context line at the end
344     // with a from line number of 0. Don't show expand links in that case either.
345     if (!$(last_line).hasClass('add') && !$(last_line).hasClass('remove') && fromLineNumber(last_line) != 0)
346       $(file_diff).append(expandBarHtml(file_name, ABOVE));
347   }
348
349   function expandBarHtml(file_name, opt_direction) {
350     var html = '<div class="ExpandBar">' +
351         '<pre class="ExpandArea Expand' + ABOVE + '"></pre>' +
352         '<div class="ExpandLinkContainer LinkContainer"><span class="ExpandText">expand: </span>';
353
354     // FIXME: If there are <100 line to expand, don't show the expand-100 link.
355     // If there are <20 lines to expand, don't show the expand-20 link.
356     if (!opt_direction || opt_direction == ABOVE) {
357       html += expandLinkHtml(ABOVE, 100) +
358           expandLinkHtml(ABOVE, 20);
359     }
360
361     html += expandLinkHtml(ALL);
362
363     if (!opt_direction || opt_direction == BELOW) {
364       html += expandLinkHtml(BELOW, 20) +
365         expandLinkHtml(BELOW, 100);
366     }
367
368     html += '</div><pre class="ExpandArea Expand' + BELOW + '"></pre></div>';
369     return html;
370   }
371
372   function expandLinkHtml(direction, amount) {
373     return "<a class='ExpandLink' href='javascript:' data-direction='" + direction + "' data-amount='" + amount + "'>" +
374         (amount ? amount + " " : "") + direction + "</a>";
375   }
376
377   function handleExpandLinkClick() {
378     var expand_bar = $(this).parents('.ExpandBar');
379     var file_name = expand_bar.parents('.FileDiff').children('h1')[0].textContent;
380     var expand_function = partial(expand, expand_bar[0], file_name, this.getAttribute('data-direction'), Number(this.getAttribute('data-amount')));
381     if (file_name in original_file_contents)
382       expand_function();
383     else
384       getWebKitSourceFile(file_name, expand_function, expand_bar);
385   }
386
387   function handleSideBySideLinkClick() {
388     $('.FileDiff').each(function() {
389       convertFileDiff('sidebyside', this);
390     });
391   }
392
393   function handleUnifyLinkClick() {
394     $('.FileDiff').each(function() {
395       convertFileDiff('unified', this);
396     });
397   }
398
399   function getWebKitSourceFile(file_name, onLoad, expand_bar) {
400     function handleLoad(contents) {
401       original_file_contents[file_name] = contents.split('\n');
402       patched_file_contents[file_name] = applyDiff(original_file_contents[file_name], file_name);
403       onLoad();
404     };
405
406     $.ajax({
407       url: WEBKIT_BASE_DIR + file_name,
408       context: document.body,
409       complete: function(xhr, data) {
410               if (xhr.status == 0)
411                   handleLoadError(expand_bar);
412               else
413                   handleLoad(xhr.responseText);
414       }
415     });
416   }
417
418   function replaceExpandLinkContainers(expand_bar, text) {
419     $('.ExpandLinkContainer', $(expand_bar).parents('.FileDiff')).replaceWith('<span class="ExpandText">' + text + '</span>');
420   }
421
422   function handleLoadError(expand_bar) {
423     // FIXME: In this case, try fetching the source file at the revision the patch was created at,
424     // in case the file has bee deleted.
425     // Might need to modify webkit-patch to include that data in the diff.
426     replaceExpandLinkContainers(expand_bar, "Can't expand. Is this a new or deleted file?");
427   }
428
429   var ABOVE = 'above';
430   var BELOW = 'below';
431   var ALL = 'all';
432
433   function expand(expand_bar, file_name, direction, amount) {
434     if (file_name in original_file_contents && !patched_file_contents[file_name]) {
435       // FIXME: In this case, try fetching the source file at the revision the patch was created at.
436       // Might need to modify webkit-patch to include that data in the diff.
437       replaceExpandLinkContainers(expand_bar, "Can't expand. Unable to apply patch to tip of tree.");
438       return;
439     }
440
441     var above_expansion = expand_bar.querySelector('.Expand' + ABOVE)
442     var below_expansion = expand_bar.querySelector('.Expand' + BELOW)
443
444     var above_last_line = above_expansion.querySelector('.ExpansionLine:last-of-type');
445     if (!above_last_line) {
446       var diff_section = expand_bar.previousElementSibling;
447       above_last_line = diff_section.querySelector('.LineContainer:last-of-type');
448     }
449
450     var above_last_line_num, above_last_from_line_num;
451     if (above_last_line) {
452       above_last_line_num = toLineNumber(above_last_line);
453       above_last_from_line_num = fromLineNumber(above_last_line);
454     } else
455       above_last_from_line_num = above_last_line_num = 0;
456
457     var below_first_line = below_expansion.querySelector('.ExpansionLine');
458     if (!below_first_line) {
459       var diff_section = expand_bar.nextElementSibling;
460       if (diff_section)
461         below_first_line = diff_section.querySelector('.LineContainer');
462     }
463
464     var below_first_line_num, below_first_from_line_num;
465     if (below_first_line) {
466       below_first_line_num = toLineNumber(below_first_line) - 1;
467       below_first_from_line_num = fromLineNumber(below_first_line) - 1;
468     } else
469       below_first_from_line_num = below_first_line_num = patched_file_contents[file_name].length - 1;
470
471     var start_line_num, start_from_line_num;
472     var end_line_num;
473
474     if (direction == ABOVE) {
475       start_from_line_num = above_last_from_line_num;
476       start_line_num = above_last_line_num;
477       end_line_num = Math.min(start_line_num + amount, below_first_line_num);
478     } else if (direction == BELOW) {
479       end_line_num = below_first_line_num;
480       start_line_num = Math.max(end_line_num - amount, above_last_line_num)
481       start_from_line_num = Math.max(below_first_from_line_num - amount, above_last_from_line_num)
482     } else { // direction == ALL
483       start_line_num = above_last_line_num;
484       start_from_line_num = above_last_from_line_num;
485       end_line_num = below_first_line_num;
486     }
487
488     var expansion_area;
489     // Filling in all the remaining lines. Overwrite the expand links.
490     if (start_line_num == above_last_line_num && end_line_num == below_first_line_num) {
491       expansion_area = expand_bar.querySelector('.ExpandLinkContainer');
492       expansion_area.innerHTML = '';
493     } else {
494       expansion_area = direction == ABOVE ? above_expansion : below_expansion;
495     }
496
497     insertLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num);
498   }
499
500   function unifiedLine(from, to, contents, is_expansion_line, opt_className, opt_attributes) {
501     var className = is_expansion_line ? 'ExpansionLine' : 'LineContainer Line';
502     if (opt_className)
503       className += ' ' + opt_className;
504
505     var lineNumberClassName = is_expansion_line ? 'expansionLineNumber' : 'lineNumber';
506
507     var line = $('<div class="' + className + '" ' + (opt_attributes || '') + '>' +
508         '<span class="from ' + lineNumberClassName + '">' + (from || '&nbsp;') +
509         '</span><span class="to ' + lineNumberClassName + '">' + (to || '&nbsp;') +
510         '</span> <span class="text"></span>' +
511         '</div>');
512     // Use text instead of innerHTML to avoid evaluting HTML.
513     $('.text', line).text(contents);
514     return line;
515   }
516
517   function unifiedExpansionLine(line_number, contents) {
518     return unifiedLine(line_number, line_number, contents, true);
519   }
520
521   function sideBySideExpansionLine(line_number, contents) {
522     var line = $('<div class="ExpansionLine"></div>');
523     line.append(lineSide('from', contents, true, line_number));
524     line.append(lineSide('to', contents, true, line_number));
525     return line;
526   }
527
528   function lineSide(side, contents, is_expansion_line, opt_line_number, opt_attributes, opt_class) {
529     var class_name = '';
530     if (opt_attributes || opt_class) {
531       class_name = 'class="';
532       if (opt_attributes)
533         class_name += is_expansion_line ? 'ExpansionLine' : 'Line';
534       class_name += ' ' + (opt_class || '') + '"';
535     }
536
537     var attributes = opt_attributes || '';
538
539     var line_side = $('<div class="LineSide">' +
540         '<div ' + attributes + ' ' + class_name + '>' +
541           '<span class="' + side + ' ' + (is_expansion_line ? 'expansionLineNumber' : 'lineNumber') + '">' +
542               (opt_line_number || '&nbsp;') +
543           '</span>' +
544           '<span class="text"></span>' +
545         '</div>' +
546         '</div>');
547
548     // Use text instead of innerHTML to avoid evaluting HTML.
549     $('.text', line_side).text(contents);
550     return line_side;
551   }
552
553   function insertLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num) {
554     var fragment = document.createDocumentFragment();
555     var is_side_by_side = isDiffSideBySide(files[file_name]);
556
557     for (var i = 0; i < end_line_num - start_line_num; i++) {
558       // FIXME: from line numbers are wrong
559       var line_number = start_from_line_num + i + 1;
560       var contents = patched_file_contents[file_name][start_line_num + i];
561       var line = is_side_by_side ? sideBySideExpansionLine(line_number, contents) : unifiedExpansionLine(line_number, contents);
562       fragment.appendChild(line[0]);
563     }
564
565     if (direction == BELOW)
566       expansion_area.insertBefore(fragment, expansion_area.firstChild);
567     else
568       expansion_area.appendChild(fragment);
569   }
570
571   function hunkStartingLine(patched_file, context, prev_line, hunk_num) {
572     var PATCH_FUZZ = 2;
573     var current_line = -1;
574     var last_context_line = context[context.length - 1];
575     if (patched_file[prev_line] == last_context_line)
576       current_line = prev_line + 1;
577     else {
578       for (var i = prev_line - PATCH_FUZZ; i < prev_line + PATCH_FUZZ; i++) {
579         if (patched_file[i] == last_context_line)
580           current_line = i + 1;
581       }
582
583       if (current_line == -1) {
584         console.log('Hunk #' + hunk_num + ' FAILED.');
585         return -1;
586       }
587     }
588
589     // For paranoia sake, confirm the rest of the context matches;
590     for (var i = 0; i < context.length - 1; i++) {
591       if (patched_file[current_line - context.length + i] != context[i]) {
592         console.log('Hunk #' + hunk_num + ' FAILED. Did not match preceding context.');
593         return -1;
594       }
595     }
596
597     return current_line;
598   }
599
600   function fromLineNumber(line) {
601     var node = line.querySelector('.from');
602     return node ? Number(node.textContent) : 0;
603   }
604
605   function toLineNumber(line) {
606     var node = line.querySelector('.to');
607     return node ? Number(node.textContent) : 0;
608   }
609
610   function textContentsFor(line) {
611     // Just get the first match since a side-by-side diff has two lines with text inside them for
612     // unmodified lines in the diff.
613     return $('.text', line).first().text();
614   }
615
616   function lineNumberForFirstNonContextLine(patched_file, line, prev_line, context, hunk_num) {
617     if (context.length) {
618       var prev_line_num = fromLineNumber(prev_line) - 1;
619       return hunkStartingLine(patched_file, context, prev_line_num, hunk_num);
620     }
621
622     if (toLineNumber(line) == 1 || fromLineNumber(line) == 1)
623       return 0;
624
625     console.log('Failed to apply patch. Adds or removes lines before any context lines.');
626     return -1;
627   }
628
629   function applyDiff(original_file, file_name) {
630     var diff_sections = files[file_name].getElementsByClassName('DiffSection');
631     var patched_file = original_file.concat([]);
632
633     // Apply diffs in reverse order to avoid needing to keep track of changing line numbers.
634     for (var i = diff_sections.length - 1; i >= 0; i--) {
635       var section = diff_sections[i];
636       var lines = section.getElementsByClassName('Line');
637       var current_line = -1;
638       var context = [];
639       var hunk_num = i + 1;
640
641       for (var j = 0, lines_len = lines.length; j < lines_len; j++) {
642         var line = lines[j];
643         var line_contents = textContentsFor(line);
644         if ($(line).hasClass('add')) {
645           if (current_line == -1) {
646             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
647             if (current_line == -1)
648               return null;
649           }
650
651           patched_file.splice(current_line, 0, line_contents);
652           current_line++;
653         } else if ($(line).hasClass('remove')) {
654           if (current_line == -1) {
655             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
656             if (current_line == -1)
657               return null;
658           }
659
660           if (patched_file[current_line] != line_contents) {
661             console.log('Hunk #' + hunk_num + ' FAILED.');
662             return null;
663           }
664
665           patched_file.splice(current_line, 1);
666         } else if (current_line == -1) {
667           context.push(line_contents);
668         } else if (line_contents != patched_file[current_line]) {
669           console.log('Hunk #' + hunk_num + ' FAILED. Context at end did not match');
670           return null;
671         } else {
672           current_line++;
673         }
674       }
675     }
676
677     return patched_file;
678   }
679
680   function openOverallComments(e) {
681     $('.overallComments textarea').addClass('open');
682     $('#statusBubbleContainer').addClass('wrap');
683   }
684
685   function onBodyResize() {
686     updateToolbarAnchorState();
687   }
688
689   function updateToolbarAnchorState() {
690     var has_scrollbar = window.innerWidth > document.documentElement.offsetWidth;
691     $('#toolbar').toggleClass('anchored', has_scrollbar);
692   }
693
694   $(document).ready(function() {
695     crawlDiff();
696     fetchHistory();
697     $(document.body).prepend('<div id="message">' +
698         '<div class="help">Select line numbers to add a comment.' +
699           '<div class="DiffLinks LinkContainer">' +
700             '<a href="javascript:" class="unify-link">unified</a>' +
701             '<a href="javascript:" class="side-by-side-link">side-by-side</a>' +
702           '</div>' +
703         '</div>' +
704         '<div class="commentStatus"></div>' +
705         '</div>');
706     $(document.body).append('<div id="toolbar">' +
707         '<div class="overallComments">' +
708             '<textarea placeholder="Overall comments"></textarea>' +
709         '</div>' +
710         '<div>' +
711           '<span id="statusBubbleContainer"></span>' +
712           '<span class="actions">' +
713               '<span class="links"><span class="bugLink"></span></span>' +
714               '<span id="flagContainer"></span>' +
715               '<button id="preview_comments">Preview</button>' +
716               '<button id="post_comments">Publish</button> ' +
717           '</span></div>' +
718         '</div>' +
719         '</div>');
720
721     $('.overallComments textarea').bind('click', openOverallComments);
722
723     $(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>');
724
725     // Create a dummy iframe and monitor resizes in it's contentWindow to detect when the top document's body changes size.
726     // FIXME: Should we setTimeout throttle these?
727     var resize_iframe = $('<iframe class="pseudo_resize_event_iframe"></iframe>');
728     $(document.body).append(resize_iframe);
729     $(resize_iframe[0].contentWindow).bind('resize', onBodyResize);
730
731     updateToolbarAnchorState();
732   });
733
734   function isDiffSideBySide(file_diff) {
735     return diffState(file_diff) == 'sidebyside';
736   }
737
738   function diffState(file_diff) {
739     var diff_state = $(file_diff).attr('data-diffstate');
740     return diff_state || 'unified';
741   }
742
743   function unifyLine(line, from, to, contents, classNames, attributes, id) {
744     var new_line = unifiedLine(from, to, contents, false, classNames, attributes);
745     var old_line = $(line);
746     if (!old_line.hasClass('LineContainer'))
747       old_line = old_line.parents('.LineContainer');
748
749     var comments = commentsToTransferFor($(document.getElementById(id)));
750     old_line.after(comments);
751     old_line.replaceWith(new_line);
752   }
753
754   function convertFileDiff(diff_type, file_diff) {
755     if (diffState(file_diff) == diff_type)
756       return;
757
758     $(file_diff).attr('data-diffstate', diff_type);
759
760     $('.Line', file_diff).each(function() {
761       convertLine(diff_type, this);
762     });
763
764     $('.ExpansionLine', file_diff).each(function() {
765       convertExpansionLine(diff_type, this);
766     });
767   }
768
769   function convertLine(diff_type, line) {
770     var convert_function = diff_type == 'sidebyside' ? sideBySideifyLine : unifyLine;
771     var from = fromLineNumber(line);
772     var to = toLineNumber(line);
773     var contents = textContentsFor(line);
774     var classNames = classNamesForMovingLine(line);
775     var attributes = attributesForMovingLine(line);
776     var id = line.id;
777     convert_function(line, from, to, contents, classNames, attributes, id)
778   }
779
780   function classNamesForMovingLine(line) {
781     var classParts = line.className.split(' ');
782     var classBuffer = [];
783     for (var i = 0; i < classParts.length; i++) {
784       var part = classParts[i];
785       if (part != 'LineContainer' && part != 'Line')
786         classBuffer.push(part);
787     }
788     return classBuffer.join(' ');
789   }
790
791   function attributesForMovingLine(line) {
792     var attributesBuffer = ['id=' + line.id];
793     // Make sure to keep all data- attributes.
794     $(line.attributes).each(function() {
795       if (this.name.indexOf('data-') == 0)
796         attributesBuffer.push(this.name + '=' + this.value);
797     });
798     return attributesBuffer.join(' ');
799   }
800
801   // FIXME: Put removed lines to the left of their corresponding added lines.
802   // FIXME: Allow for converting an individual file to side-by-side.
803   function sideBySideifyLine(line, from, to, contents, classNames, attributes, id) {
804     var from_class = '';
805     var to_class = '';
806     var from_attributes = '';
807     var to_attributes = '';
808     var from_contents = contents;
809     var to_contents = contents;
810
811     if (from && !to) { // This is a remove line.
812       from_class = classNames;
813       from_attributes = attributes;
814       to_contents = '';
815     } else if (to && !from) { // This is an add line.
816       to_class = classNames;
817       to_attributes = attributes;
818       from_contents = '';
819     }
820
821     var container_class = 'LineContainer';
822     var container_attributes = '';
823     if (!to_attributes && !from_attributes) {
824       container_attributes = attributes;
825       container_class += ' Line ' + classNames;
826     }
827
828     var new_line = $('<div ' + container_attributes + ' class="' + container_class + '"></div>');
829     new_line.append(lineSide('from', from_contents, false, from, from_attributes, from_class));
830     new_line.append(lineSide('to', to_contents, false, to, to_attributes, to_class));
831
832     $(line).replaceWith(new_line);
833
834     var line = $(document.getElementById(id));
835     line.after(commentsToTransferFor(line));
836   }
837
838   function convertExpansionLine(diff_type, line) {
839     var convert_function = diff_type == 'sidebyside' ? sideBySideExpansionLine : unifiedExpansionLine;
840     var contents = textContentsFor(line);
841     var line_number = fromLineNumber(line);
842     var new_line = convert_function(line_number, contents);
843     $(line).replaceWith(new_line);
844   }
845
846   function commentsToTransferFor(line) {
847     var fragment = document.createDocumentFragment();
848
849     previousCommentsFor(line).each(function() {
850       fragment.appendChild(this);
851     });
852
853     var active_comments = activeCommentFor(line);
854     var num_active_comments = active_comments.size();
855     if (num_active_comments > 0) {
856       if (num_active_comments > 1)
857         console.log('ERROR: There is more than one active comment for ' + line.attr('id') + '.');
858
859       var parent = active_comments[0].parentNode;
860       var frozenComment = parent.nextSibling;
861       fragment.appendChild(parent);
862       fragment.appendChild(frozenComment);
863     }
864
865     return fragment;
866   }
867
868   function discardComment() {
869     var line_id = $(this).parentsUntil('.comment').parent().find('textarea').attr('data-comment-for');
870     var line = $('#' + line_id)
871     findCommentBlockFor(line).slideUp('fast', function() {
872       $(this).remove();
873       line.removeAttr('data-has-comment');
874       trimCommentContextToBefore(line);
875     });
876   }
877
878   function unfreezeComment() {
879     $(this).prev().show();
880     $(this).remove();
881   }
882
883   $('.side-by-side-link').live('click', handleSideBySideLinkClick);
884   $('.unify-link').live('click', handleUnifyLinkClick);
885   $('.ExpandLink').live('click', handleExpandLinkClick);
886   $('.comment .discard').live('click', discardComment);
887   $('.frozenComment').live('click', unfreezeComment);
888
889   $('.comment .ok').live('click', function() {
890     var comment_textarea = $(this).parentsUntil('.comment').parent().find('textarea');
891     if (comment_textarea.val().trim() == '') {
892       discardComment.call(this);
893       return;
894     }
895     var line_id = comment_textarea.attr('data-comment-for');
896     var line = $('#' + line_id)
897     findCommentBlockFor(line).hide().after($('<div class="frozenComment"></div>').text(comment_textarea.val()));
898   });
899
900   function focusOn(comment) {
901     $('.focused').removeClass('focused');
902     if (comment.length == 0)
903       return;
904     $(document).scrollTop(comment.addClass('focused').position().top - window.innerHeight/2);
905   }
906
907   function focusNextComment() {
908     var comments = $('.previousComment');
909     if (comments.length == 0)
910       return;
911     var index = comments.index($('.focused'));
912     // Notice that -1 gets mapped to 0.
913     focusOn($(comments.get(index + 1)));
914   }
915
916   function focusPreviousComment() {
917     var comments = $('.previousComment');
918     if (comments.length == 0)
919       return;
920     var index = comments.index($('.focused'));
921     if (index == -1)
922       index = comments.length;
923     if (index == 0) {
924       focusOn([]);
925       return;
926     }
927     focusOn($(comments.get(index - 1)));
928   }
929
930   var kCharCodeForN = 'n'.charCodeAt(0);
931   var kCharCodeForP = 'p'.charCodeAt(0);
932
933   $('body').live('keypress', function() {
934     // FIXME: There's got to be a better way to avoid seeing these keypress
935     // events.
936     if (event.target.nodeName == 'TEXTAREA')
937       return;
938     if (event.charCode == kCharCodeForN)
939       focusNextComment();
940     else if (event.charCode == kCharCodeForP)
941       focusPreviousComment();
942   });
943
944   function contextLinesFor(line_id) {
945     return $('div[data-comment-base-line~="' + line_id + '"]');
946   }
947
948   function numberFrom(line_id) {
949     return Number(line_id.replace('line', ''));
950   }
951
952   function trimCommentContextToBefore(line) {
953     var base_line_id = line.attr('data-comment-base-line');
954     var line_to_trim_to = numberFrom(line.attr('id'));
955     contextLinesFor(base_line_id).each(function() {
956       var id = $(this).attr('id');
957       if (numberFrom(id) > line_to_trim_to)
958         return;
959
960       removeDataCommentBaseLine(this, base_line_id);
961       if (!$(this).attr('data-comment-base-line'))
962         $(this).removeClass('commentContext');
963     });
964   }
965
966   var in_drag_select = false;
967
968   function stopDragSelect() {
969     $('.selected').removeClass('selected');
970     in_drag_select = false;
971   }
972
973   $('.lineNumber').live('click', function() {
974     var line = $(this).parent();
975     if (line.hasClass('commentContext'))
976       trimCommentContextToBefore(line.prev());
977   }).live('mousedown', function() {
978     in_drag_select = true;
979     $(lineFromLineDescendant(this)).addClass('selected');
980     event.preventDefault();
981   });
982
983   $('.LineContainer').live('mouseenter', function() {
984     if (!in_drag_select)
985       return;
986
987     var line = lineFromLineContainer(this);
988     line.addClass('selected');
989   }).live('mouseup', function() {
990     if (!in_drag_select)
991       return;
992     var selected = $('.selected');
993     var should_add_comment = !selected.last().next().hasClass('commentContext');
994     selected.addClass('commentContext');
995
996     var id;
997     if (should_add_comment) {
998       var last = lineFromLineDescendant(selected.last()[0]);
999       addCommentFor($(last));
1000       id = last.id;
1001     } else {
1002       id = selected.last().next()[0].getAttribute('data-comment-base-line');
1003     }
1004
1005     selected.each(function() {
1006       addDataCommentBaseLine(this, id);
1007     });
1008   });
1009
1010   function addDataCommentBaseLine(line, id) {
1011     var val = $(line).attr('data-comment-base-line');
1012
1013     var parts = val ? val.split(' ') : [];
1014     for (var i = 0; i < parts.length; i++) {
1015       if (parts[i] == id)
1016         return;
1017     }
1018
1019     parts.push(id);
1020     $(line).attr('data-comment-base-line', parts.join(' '));
1021   }
1022
1023   function removeDataCommentBaseLine(line, id) {
1024     var val = $(line).attr('data-comment-base-line');
1025     if (!val)
1026       return;
1027
1028     var parts = val.split(' ');
1029     var newVal = [];
1030     for (var i = 0; i < parts.length; i++) {
1031       if (parts[i] != id)
1032         newVal.push(parts[i]);
1033     }
1034
1035     $(line).attr('data-comment-base-line', newVal.join(' '));
1036   }
1037
1038   function lineFromLineDescendant(descendant) {
1039     while (descendant && !$(descendant).hasClass('Line')) {
1040       descendant = descendant.parentNode;
1041     }
1042     return descendant;
1043   }
1044
1045   function lineFromLineContainer(lineContainer) {
1046     var line = $(lineContainer);
1047     if (!line.hasClass('Line'))
1048       line = $('.Line', line);
1049     return line;
1050   }
1051
1052   $('.DiffSection').live('mouseleave', stopDragSelect).live('mouseup', stopDragSelect);
1053
1054   function contextSnippetFor(line, indent) {
1055     var snippets = []
1056     contextLinesFor(line.attr('id')).each(function() {
1057       var action = ' ';
1058       if ($(this).hasClass('add'))
1059         action = '+';
1060       else if ($(this).hasClass('remove'))
1061         action = '-';
1062       snippets.push(indent + action + textContentsFor(this));
1063     });
1064     return snippets.join('\n');
1065   }
1066
1067   function fileNameFor(line) {
1068     return line.parentsUntil('.FileDiff').parent().find('h1').text();
1069   }
1070
1071   function indentFor(depth) {
1072     return (new Array(depth + 1)).join('>') + ' ';
1073   }
1074
1075   function snippetFor(line, indent) {
1076     var file_name = fileNameFor(line);
1077     var line_number = line.hasClass('remove') ? '-' + fromLineNumber(line[0]) : toLineNumber(line[0]);
1078     return indent + file_name + ':' + line_number + '\n' + contextSnippetFor(line, indent);
1079   }
1080
1081   function quotePreviousComments(comments) {
1082     var quoted_comments = [];
1083     var depth = comments.size();
1084     comments.each(function() {
1085       var indent = indentFor(depth--);
1086       var text = $(this).children('.content').text();
1087       quoted_comments.push(indent + '\n' + indent + text.split('\n').join('\n' + indent));
1088     });
1089     return quoted_comments.join('\n');
1090   }
1091
1092   $('#comment_form .winter').live('click', function() {
1093     $('#comment_form').addClass('inactive');
1094   });
1095
1096   function fillInReviewForm() {
1097     var comments_in_context = []
1098     forEachLine(function(line) {
1099       if (line.attr('data-has-comment') != 'true')
1100         return;
1101       var comment = findCommentBlockFor(line).children('textarea').val().trim();
1102       if (comment == '')
1103         return;
1104       var previous_comments = previousCommentsFor(line);
1105       var snippet = snippetFor(line, indentFor(previous_comments.size() + 1));
1106       var quoted_comments = quotePreviousComments(previous_comments);
1107       var comment_with_context = [];
1108       comment_with_context.push(snippet);
1109       if (quoted_comments != '')
1110         comment_with_context.push(quoted_comments);
1111       comment_with_context.push('\n' + comment);
1112       comments_in_context.push(comment_with_context.join('\n'));
1113     });
1114     var comment = $('.overallComments textarea').val().trim();
1115     if (comment != '')
1116       comment += '\n\n';
1117     comment += comments_in_context.join('\n\n');
1118     if (comments_in_context.length > 0)
1119       comment = 'View in context: ' + window.location + '\n\n' + comment;
1120     var review_form = $('#reviewform').contents();
1121     review_form.find('#comment').val(comment);
1122     review_form.find('#flags select').each(function() {
1123       var control = findControlForFlag(this);
1124       if (!control.size())
1125         return;
1126       $(this).attr('selectedIndex', control.attr('selectedIndex'));
1127     });
1128   }
1129
1130   $('#preview_comments').live('click', function() {
1131     fillInReviewForm();
1132     $('#comment_form').removeClass('inactive');
1133   });
1134
1135   $('#post_comments').live('click', function() {
1136     fillInReviewForm();
1137     $('#reviewform').contents().find('form').submit();
1138   });
1139 })();