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