2010-12-14 Ojan Vafai <ojan@chromium.org>
[WebKit-https.git] / BugsSite / 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
68   function idForLine(number) {
69     return 'line' + number;
70   }
71
72   function nextLineID() {
73     return idForLine(next_line_id++);
74   }
75
76   function forEachLine(callback) {
77     for (var i = 0; i < next_line_id; ++i) {
78       callback($('#' + idForLine(i)));
79     }
80   }
81
82   function idify() {
83     this.id = nextLineID();
84   }
85
86   function hoverify() {
87     $(this).hover(function() {
88       $(this).addClass('hot');
89     },
90     function () {
91       $(this).removeClass('hot');
92     });
93   }
94
95   function previousCommentsFor(line) {
96     var comments = [];
97     var position = line;
98     while (position.next() && position.next().hasClass('previousComment')) {
99       position = position.next();
100       comments.push(position.get());
101     }
102     return $(comments);
103   }
104
105   function findCommentPositionFor(line) {
106     var position = line;
107     while (position.next() && position.next().hasClass('previousComment'))
108       position = position.next();
109     return position;
110   }
111
112   function findCommentBlockFor(line) {
113     var comment_block = findCommentPositionFor(line).next();
114     if (!comment_block.hasClass('comment'))
115       return;
116     return comment_block;
117   }
118
119   function insertCommentFor(line, block) {
120     findCommentPositionFor(line).after(block);
121   }
122
123   function addCommentFor(line) {
124     if (line.attr('data-has-comment')) {
125       // FIXME: This query is overly complex because we place comment blocks
126       // after Lines.  Instead, comment blocks should be children of Lines.
127       findCommentPositionFor(line).next().next().filter('.frozenComment').each(unfreezeComment);
128       return;
129     }
130     line.attr('data-has-comment', 'true');
131     line.addClass('commentContext');
132
133     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>');
134     insertCommentFor(line, comment_block);
135     comment_block.hide().slideDown('fast', function() {
136       $(this).children('textarea').focus();
137     });
138   }
139
140   function addCommentField() {
141     var id = $(this).attr('data-comment-for');
142     if (!id)
143       id = this.id;
144     addCommentFor($('#' + id));
145   }
146
147   function addPreviousComment(line, author, comment_text) {
148     var comment_block = $('<div data-comment-for="' + line.attr('id') + '" class="previousComment"></div>');
149     var author_block = $('<div class="author"></div>').text(author + ':');
150     var text_block = $('<div class="content"></div>').text(comment_text);
151     comment_block.append(author_block).append(text_block).each(hoverify).click(addCommentField);
152     insertCommentFor(line, comment_block);
153   }
154
155   function displayPreviousComments(comments) {
156     for (var i = 0; i < comments.length; ++i) {
157       var author = comments[i].author;
158       var file_name = comments[i].file_name;
159       var line_number = comments[i].line_number;
160       var comment_text = comments[i].comment_text;
161
162       var file = files[file_name];
163
164       var query = '.Line .to';
165       if (line_number[0] == '-') {
166         // The line_number represent a removal.  We need to adjust the query to
167         // look at the "from" lines.
168         query = '.Line .from';
169         // Trim off the '-' control character.
170         line_number = line_number.substr(1);
171       }
172
173       $(file).find(query).each(function() {
174         if ($(this).text() != line_number)
175           return;
176         var line = $(this).parent();
177         addPreviousComment(line, author, comment_text);
178       });
179     }
180     if (comments.length == 0)
181       return;
182     descriptor = comments.length + ' comment';
183     if (comments.length > 1)
184       descriptor += 's';
185     $('.message .commentStatus').text('This patch has ' + descriptor + '.  Scroll through them with the "n" and "p" keys.');
186   }
187
188   function scanForComments(author, text) {
189     var comments = []
190     var lines = text.split('\n');
191     for (var i = 0; i < lines.length; ++i) {
192       var parts = lines[i].match(/^([> ]+)([^:]+):(-?\d+)$/);
193       if (!parts)
194         continue;
195       var quote_markers = parts[1];
196       var file_name = parts[2];
197       var line_number = parts[3];
198       if (!file_name in files)
199         continue;
200       while (i < lines.length && lines[i].length > 0 && lines[i][0] == '>')
201         ++i;
202       var comment_lines = [];
203       while (i < lines.length && (lines[i].length == 0 || lines[i][0] != '>')) {
204         comment_lines.push(lines[i]);
205         ++i;
206       }
207       --i; // Decrement i because the for loop will increment it again in a second.
208       var comment_text = comment_lines.join('\n').trim();
209       comments.push({
210         'author': author,
211         'file_name': file_name,
212         'line_number': line_number,
213         'comment_text': comment_text
214       });
215     }
216     return comments;
217   }
218
219   function isReviewFlag(select) {
220     return $(select).attr('title') == 'Request for patch review.';
221   }
222
223   function isCommitQueueFlag(select) {
224     return $(select).attr('title').match(/commit-queue/);
225   }
226
227   function findControlForFlag(select) {
228     if (isReviewFlag(select))
229       return $('#toolbar .review select');
230     else if (isCommitQueueFlag(select))
231       return $('#toolbar .commitQueue select');
232     return $();
233   }
234
235   function addFlagsForAttachment(details) {
236     var flag_control = "<select><option></option><option>?</option><option>+</option><option>-</option></select>";
237     $('#flagContainer').append(
238       $('<span class="review"> r: ' + flag_control + '</span>')).append(
239       $('<span class="commitQueue"> cq: ' + flag_control + '</span>'));
240
241     details.find('#flags select').each(function() {
242       var requestee = $(this).parent().siblings('td:first-child').text().trim();
243       if (requestee.length) {
244         // Remove trailing ':'.
245         requestee = requestee.substr(0, requestee.length - 1);
246         requestee = ' (' + requestee + ')';
247       }
248       var control = findControlForFlag(this)
249       control.attr('selectedIndex', $(this).attr('selectedIndex'));
250       control.parent().prepend(requestee);
251     });
252   }
253
254   function fetchHistory() {
255     $.get('attachment.cgi?id=' + attachment_id + '&action=edit', function(data) {
256       var bug_id = /Attachment \d+ Details for Bug (\d+)/.exec(data)[1];
257       $.get('show_bug.cgi?id=' + bug_id, function(data) {
258         var comments = [];
259         $(data).find('.bz_comment').each(function() {
260           var author = $(this).find('.email').text();
261           var text = $(this).find('.bz_comment_text').text();
262           var comment_marker = '(From update of attachment ' + attachment_id + ' .details.)';
263           if (text.match(comment_marker))
264             $.merge(comments, scanForComments(author, text));
265         });
266         displayPreviousComments(comments);
267       });
268
269       var details = $(data);
270       addFlagsForAttachment(details);
271       $('#statusBubbleContainer').append($('<iframe style="margin-top:2px;" class="statusBubble" src="https://webkit-commit-queue.appspot.com/status-bubble/' + attachment_id + '" scrolling="no"></iframe>'));
272       $('#toolbar .bugLink').html('<a href="/show_bug.cgi?id=' + bug_id + '" target="_blank">Bug ' + bug_id + '</a>');
273     });
274   }
275
276   function crawlDiff() {
277     $('.Line').each(idify).each(hoverify);
278     $('.FileDiff').each(function() {
279       var file_name = $(this).children('h1').text();
280       files[file_name] = this;
281       addExpandLinks(file_name);
282     });
283   }
284
285   function addExpandLinks(file_name) {
286     if (file_name.indexOf('ChangeLog') != -1)
287       return;
288
289     var file_diff = files[file_name];
290     $('.context', file_diff).detach();
291
292     var expand_bar_index = 0;
293
294     // Don't show the links to expand upwards/downwards if the patch starts/ends without context
295     // lines, i.e. starts/ends with add/remove lines.
296     var first_line = file_diff.querySelector('.Line');
297     if (!$(first_line).hasClass('add') && !$(first_line).hasClass('remove'))
298       $('h1', file_diff).after(expandBarHtml(file_name, BELOW))
299
300     $('br').replaceWith(expandBarHtml(file_name));
301
302     var last_line = file_diff.querySelector('.Line:last-of-type');
303     // Some patches for new files somehow end up with an empty context line at the end
304     // with a from line number of 0. Don't show expand links in that case either.
305     if (!$(last_line).hasClass('add') && !$(last_line).hasClass('remove') && fromLineNumber(last_line) != 0)
306       $(file_diff).append(expandBarHtml(file_name, ABOVE));
307   }
308
309   function expandBarHtml(file_name, opt_direction) {
310     var html = '<div class="ExpandBar">' +
311         '<pre class="ExpandArea Expand' + ABOVE + '"></pre>' +
312         '<div class="ExpandLinkContainer"><span class="ExpandText">expand: </span>';
313
314     // FIXME: If there are <100 line to expand, don't show the expand-100 link.
315     // If there are <20 lines to expand, don't show the expand-20 link.
316     if (!opt_direction || opt_direction == ABOVE) {
317       html += expandLinkHtml(ABOVE, 100) +
318           expandLinkHtml(ABOVE, 20);
319     }
320
321     html += expandLinkHtml(ALL);
322
323     if (!opt_direction || opt_direction == BELOW) {
324       html += expandLinkHtml(BELOW, 20) +
325         expandLinkHtml(BELOW, 100);
326     }
327
328     html += '</div><pre class="ExpandArea Expand' + BELOW + '"></pre></div>';
329     return html;
330   }
331
332   function expandLinkHtml(direction, amount) {
333     return "<a class='ExpandLink' href='javascript:' data-direction='" + direction + "' data-amount='" + amount + "'>" +
334         (amount ? amount + " " : "") + direction + "</a>";
335   }
336
337   $(window).bind('click', function (e) {
338     var target = e.target;
339     if (target.className != 'ExpandLink')
340       return;
341
342     // Can't use $ here because something in the window's scope sets $ to something other than jQuery.
343     var expand_bar = jQuery(target).parents('.ExpandBar');
344     var file_name = expand_bar.parents('.FileDiff').children('h1')[0].textContent;
345     var expand_function = partial(expand, expand_bar[0], file_name, target.getAttribute('data-direction'), Number(target.getAttribute('data-amount')));
346     if (file_name in original_file_contents)
347       expand_function();
348     else
349       getWebKitSourceFile(file_name, expand_function, expand_bar);
350   });
351
352   function getWebKitSourceFile(file_name, onLoad, expand_bar) {
353     function handleLoad(contents) {
354       original_file_contents[file_name] = contents.split('\n');
355       patched_file_contents[file_name] = applyDiff(original_file_contents[file_name], file_name);
356       onLoad();
357     };
358
359     $.ajax({
360       url: WEBKIT_BASE_DIR + file_name,
361       context: document.body,
362       complete: function(xhr, data) {
363               if (xhr.status == 0)
364                   handleLoadError(expand_bar);
365               else
366                   handleLoad(xhr.responseText);
367       }
368     });
369   }
370
371   function replaceExpandLinkContainers(expand_bar, text) {
372     $('.ExpandLinkContainer', $(expand_bar).parents('.FileDiff')).replaceWith('<span class="ExpandText">' + text + '</span>');
373   }
374
375   function handleLoadError(expand_bar) {
376     // FIXME: In this case, try fetching the source file at the revision the patch was created at,
377     // in case the file has bee deleted.
378     // Might need to modify webkit-patch to include that data in the diff.
379     replaceExpandLinkContainers(expand_bar, "Can't expand. Is this a new or deleted file?");
380   }
381
382   var ABOVE = 'above';
383   var BELOW = 'below';
384   var ALL = 'all';
385
386   function expand(expand_bar, file_name, direction, amount) {
387     if (file_name in original_file_contents && !patched_file_contents[file_name]) {
388       // FIXME: In this case, try fetching the source file at the revision the patch was created at.
389       // Might need to modify webkit-patch to include that data in the diff.
390       replaceExpandLinkContainers(expand_bar, "Can't expand. Unable to apply patch to tip of tree.");
391       return;
392     }
393
394     var above_expansion = expand_bar.querySelector('.Expand' + ABOVE)
395     var below_expansion = expand_bar.querySelector('.Expand' + BELOW)
396
397     var above_last_line = above_expansion.querySelector('.ExpansionLine:last-of-type');
398     if (!above_last_line) {
399       var diff_section = expand_bar.previousElementSibling;
400       above_last_line = diff_section.querySelector('.Line:last-of-type');
401     }
402
403     var above_last_line_num, above_last_from_line_num;
404     if (above_last_line) {
405       above_last_line_num = toLineNumber(above_last_line);
406       above_last_from_line_num = fromLineNumber(above_last_line);
407     } else
408       above_last_from_line_num = above_last_line_num = 0;
409
410     var below_first_line = below_expansion.querySelector('.ExpansionLine');
411     if (!below_first_line) {
412       var diff_section = expand_bar.nextElementSibling;
413       if (diff_section)
414         below_first_line = diff_section.querySelector('.Line');
415     }
416
417     var below_first_line_num, below_first_from_line_num;
418     if (below_first_line) {
419       below_first_line_num = toLineNumber(below_first_line) - 1;
420       below_first_from_line_num = fromLineNumber(below_first_line) - 1;
421     } else
422       below_first_from_line_num = below_first_line_num = patched_file_contents[file_name].length - 1;
423
424     var start_line_num, start_from_line_num;
425     var end_line_num;
426
427     if (direction == ABOVE) {
428       start_from_line_num = above_last_from_line_num;
429       start_line_num = above_last_line_num;
430       end_line_num = Math.min(start_line_num + amount, below_first_line_num);
431     } else if (direction == BELOW) {
432       end_line_num = below_first_line_num;
433       start_line_num = Math.max(end_line_num - amount, above_last_line_num)
434       start_from_line_num = Math.max(below_first_from_line_num - amount, above_last_from_line_num)
435     } else { // direction == ALL
436       start_line_num = above_last_line_num;
437       start_from_line_num = above_last_from_line_num;
438       end_line_num = below_first_line_num;
439     }
440
441     var expansion_area;
442     // Filling in all the remaining lines. Overwrite the expand links.
443     if (start_line_num == above_last_line_num && end_line_num == below_first_line_num) {
444       expansion_area = expand_bar.querySelector('.ExpandLinkContainer');
445       expansion_area.innerHTML = '';
446     } else {
447       expansion_area = direction == ABOVE ? above_expansion : below_expansion;
448     }
449
450     insertLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num);
451   }
452
453   function insertLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num) {
454     var fragment = document.createDocumentFragment();
455     for (var i = 0; i < end_line_num - start_line_num; i++) {
456       var line = document.createElement('div');
457       line.className = 'ExpansionLine';
458       // FIXME: from line numbers are wrong
459       line.innerHTML = '<span class="from expansionlineNumber">' + (start_from_line_num + i + 1) +
460           '</span><span class="to expansionlineNumber">' + (start_line_num + i + 1) +
461           '</span> <span class="text"></span>';
462       line.querySelector('.text').textContent = patched_file_contents[file_name][start_line_num + i];
463       fragment.appendChild(line);
464     }
465
466     if (direction == BELOW)
467       expansion_area.insertBefore(fragment, expansion_area.firstChild);
468     else
469       expansion_area.appendChild(fragment);
470   }
471
472   function hunkStartingLine(patched_file, context, prev_line, hunk_num) {
473     var PATCH_FUZZ = 2;
474     var current_line = -1;
475     var last_context_line = context[context.length - 1];
476     if (patched_file[prev_line] == last_context_line)
477       current_line = prev_line + 1;
478     else {
479       for (var i = prev_line - PATCH_FUZZ; i < prev_line + PATCH_FUZZ; i++) {
480         if (patched_file[i] == last_context_line)
481           current_line = i + 1;
482       }
483
484       if (current_line == -1) {
485         console.log('Hunk #' + hunk_num + ' FAILED.');
486         return -1;
487       }
488     }
489
490     // For paranoia sake, confirm the rest of the context matches;
491     for (var i = 0; i < context.length - 1; i++) {
492       if (patched_file[current_line - context.length + i] != context[i]) {
493         console.log('Hunk #' + hunk_num + ' FAILED. Did not match preceding context.');
494         return -1;
495       }
496     }
497
498     return current_line;
499   }
500
501   function fromLineNumber(line) {
502     return Number(line.querySelector('.from').textContent);
503   }
504
505   function toLineNumber(line) {
506     return Number(line.querySelector('.to').textContent);
507   }
508
509   function lineNumberForFirstNonContextLine(patched_file, line, prev_line, context, hunk_num) {
510     if (context.length) {
511       var prev_line_num = fromLineNumber(prev_line) - 1;
512       return hunkStartingLine(patched_file, context, prev_line_num, hunk_num);
513     }
514
515     if (toLineNumber(line) == 1 || fromLineNumber(line) == 1)
516       return 0;
517
518     console.log('Failed to apply patch. Adds or removes lines before any context lines.');
519     return -1;
520   }
521
522   function applyDiff(original_file, file_name) {
523     var diff_sections = files[file_name].getElementsByClassName('DiffSection');
524     var patched_file = original_file.concat([]);
525
526     // Apply diffs in reverse order to avoid needing to keep track of changing line numbers.
527     for (var i = diff_sections.length - 1; i >= 0; i--) {
528       var section = diff_sections[i];
529       var lines = section.getElementsByClassName('Line');
530       var current_line = -1;
531       var context = [];
532       var hunk_num = i + 1;
533
534       for (var j = 0, lines_len = lines.length; j < lines_len; j++) {
535         var line = lines[j];
536         var line_contents = line.querySelector('.text').textContent;
537         if ($(line).hasClass('add')) {
538           if (current_line == -1) {
539             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
540             if (current_line == -1)
541               return null;
542           }
543
544           patched_file.splice(current_line, 0, line_contents);
545           current_line++;
546         } else if ($(line).hasClass('remove')) {
547           if (current_line == -1) {
548             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
549             if (current_line == -1)
550               return null;
551           }
552
553           if (patched_file[current_line] != line_contents) {
554             console.log('Hunk #' + hunk_num + ' FAILED.');
555             return null;
556           }
557
558           patched_file.splice(current_line, 1);
559         } else if (current_line == -1) {
560           context.push(line_contents);
561         } else if (line_contents != patched_file[current_line]) {
562           console.log('Hunk #' + hunk_num + ' FAILED. Context at end did not match');
563           return null;
564         } else {
565           current_line++;
566         }
567       }
568     }
569
570     return patched_file;
571   }
572
573   function openOverallComments(e) {
574     $('.overallComments textarea').addClass('open');
575     $('#statusBubbleContainer').addClass('wrap');
576   }
577
578   $(document).ready(function() {
579     crawlDiff();
580     fetchHistory();
581     $(document.body).prepend('<div id="message"><div class="help">Select line numbers to add a comment.</div><div class="commentStatus"></div></div>');
582     $(document.body).prepend('<div id="toolbar">' +
583         '<div class="overallComments">' +
584             '<textarea placeholder="Overall comments"></textarea>' +
585         '</div>' +
586         '<div>' +
587           '<span id="statusBubbleContainer"></span>' +
588           '<span class="actions">' +
589               '<span class="links"><span class="bugLink"></span></span>' +
590               '<span id="flagContainer"></span>' +
591               '<button id="preview_comments">Preview</button>' +
592               '<button id="post_comments">Publish</button> ' +
593           '</span></div>' +
594         '</div>' +
595         '</div>');
596
597     $('.overallComments textarea').bind('click', openOverallComments);
598
599     $(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>');
600   });
601
602   function discardComment() {
603     var line_id = $(this).parentsUntil('.comment').parent().find('textarea').attr('data-comment-for');
604     var line = $('#' + line_id)
605     findCommentBlockFor(line).slideUp('fast', function() {
606       $(this).remove();
607       line.removeAttr('data-has-comment');
608       trimCommentContextToBefore(line);
609     });
610   }
611
612   function unfreezeComment() {
613     $(this).prev().show();
614     $(this).remove();
615   }
616
617   $('.comment .discard').live('click', discardComment);
618
619   $('.comment .ok').live('click', function() {
620     var comment_textarea = $(this).parentsUntil('.comment').parent().find('textarea');
621     if (comment_textarea.val().trim() == '') {
622       discardComment.call(this);
623       return;
624     }
625     var line_id = comment_textarea.attr('data-comment-for');
626     var line = $('#' + line_id)
627     findCommentBlockFor(line).hide().after($('<div class="frozenComment"></div>').text(comment_textarea.val()));
628   });
629
630   $('.frozenComment').live('click', unfreezeComment);
631
632   function focusOn(comment) {
633     $('.focused').removeClass('focused');
634     if (comment.length == 0)
635       return;
636     $(document).scrollTop(comment.addClass('focused').position().top - window.innerHeight/2);
637   }
638
639   function focusNextComment() {
640     var comments = $('.previousComment');
641     if (comments.length == 0)
642       return;
643     var index = comments.index($('.focused'));
644     // Notice that -1 gets mapped to 0.
645     focusOn($(comments.get(index + 1)));
646   }
647
648   function focusPreviousComment() {
649     var comments = $('.previousComment');
650     if (comments.length == 0)
651       return;
652     var index = comments.index($('.focused'));
653     if (index == -1)
654       index = comments.length;
655     if (index == 0) {
656       focusOn([]);
657       return;
658     }
659     focusOn($(comments.get(index - 1)));
660   }
661
662   var kCharCodeForN = 'n'.charCodeAt(0);
663   var kCharCodeForP = 'p'.charCodeAt(0);
664
665   $('body').live('keypress', function() {
666     // FIXME: There's got to be a better way to avoid seeing these keypress
667     // events.
668     if (event.target.nodeName == 'TEXTAREA')
669       return;
670     if (event.charCode == kCharCodeForN)
671       focusNextComment();
672     else if (event.charCode == kCharCodeForP)
673       focusPreviousComment();
674   });
675
676   function contextLinesFor(line) {
677     var context = [];
678     while (line.hasClass('commentContext')) {
679       $.merge(context, line);
680       line = line.prev();
681     }
682     return $(context.reverse());
683   }
684
685   function trimCommentContextToBefore(line) {
686     while (line.hasClass('commentContext') && line.attr('data-has-comment') != 'true') {
687       line.removeClass('commentContext');
688       line = line.prev();
689     }
690   }
691
692   var in_drag_select = false;
693
694   function stopDragSelect() {
695     $('.selected').removeClass('selected');
696     in_drag_select = false;
697   }
698
699   $('.lineNumber').live('click', function() {
700     var line = $(this).parent();
701     if (line.hasClass('commentContext'))
702       trimCommentContextToBefore(line.prev());
703   }).live('mousedown', function() {
704     in_drag_select = true;
705     $(this).parent().addClass('selected');
706     event.preventDefault();
707   });
708   
709   $('.Line').live('mouseenter', function() {
710     if (!in_drag_select)
711       return;
712
713     var before = $(this).prevUntil('.selected')
714     if (before.prev().hasClass('selected'))
715       before.addClass('selected');
716
717     var after = $(this).nextUntil('.selected')
718     if (after.next().hasClass('selected'))
719       after.addClass('selected');
720
721     $(this).addClass('selected');
722   }).live('mouseup', function() {
723     if (!in_drag_select)
724       return;
725     var selected = $('.selected');
726     var should_add_comment = !selected.last().next().hasClass('commentContext');
727     selected.addClass('commentContext');
728     if (should_add_comment)
729       addCommentFor(selected.last());
730   });
731
732   $('.DiffSection').live('mouseleave', stopDragSelect).live('mouseup', stopDragSelect);
733
734   function contextSnippetFor(line, indent) {
735     var snippets = []
736     contextLinesFor(line).each(function() {
737       var action = ' ';
738       if ($(this).hasClass('add'))
739         action = '+';
740       else if ($(this).hasClass('remove'))
741         action = '-';
742       var text = $(this).children('.text').text();
743       snippets.push(indent + action + text);
744     });
745     return snippets.join('\n');
746   }
747
748   function fileNameFor(line) {
749     return line.parentsUntil('.FileDiff').parent().find('h1').text();
750   }
751
752   function indentFor(depth) {
753     return (new Array(depth + 1)).join('>') + ' ';
754   }
755
756   function snippetFor(line, indent) {
757     var file_name = fileNameFor(line);
758     var line_number = line.hasClass('remove') ? '-' + line.children('.from').text() : line.children('.to').text();
759     return indent + file_name + ':' + line_number + '\n' + contextSnippetFor(line, indent);
760   }
761
762   function quotePreviousComments(comments) {
763     var quoted_comments = [];
764     var depth = comments.size();
765     comments.each(function() {
766       var indent = indentFor(depth--);
767       var text = $(this).children('.content').text();
768       quoted_comments.push(indent + '\n' + indent + text.split('\n').join('\n' + indent));
769     });
770     return quoted_comments.join('\n');
771   }
772
773   $('#comment_form .winter').live('click', function() {
774     $('#comment_form').addClass('inactive');
775   });
776
777   function fillInReviewForm() {
778     var comments_in_context = []
779     forEachLine(function(line) {
780       if (line.attr('data-has-comment') != 'true')
781         return;
782       var comment = findCommentBlockFor(line).children('textarea').val().trim();
783       if (comment == '')
784         return;
785       var previous_comments = previousCommentsFor(line);
786       var snippet = snippetFor(line, indentFor(previous_comments.size() + 1));
787       var quoted_comments = quotePreviousComments(previous_comments);
788       var comment_with_context = [];
789       comment_with_context.push(snippet);
790       if (quoted_comments != '')
791         comment_with_context.push(quoted_comments);
792       comment_with_context.push('\n' + comment);
793       comments_in_context.push(comment_with_context.join('\n'));
794     });
795     var comment = $('.overallComments textarea').val().trim();
796     if (comment != '')
797       comment += '\n\n';
798     comment += comments_in_context.join('\n\n');
799     if (comments_in_context.length > 0)
800       comment = 'View in context: ' + window.location + '\n\n' + comment;
801     var review_form = $('#reviewform').contents();
802     review_form.find('#comment').val(comment);
803     review_form.find('#flags select').each(function() {
804       var control = findControlForFlag(this);
805       if (!control.size())
806         return;
807       $(this).attr('selectedIndex', control.attr('selectedIndex'));
808     });
809   }
810
811   $('#preview_comments').live('click', function() {
812     fillInReviewForm();
813     $('#comment_form').removeClass('inactive');
814   });
815
816   $('#post_comments').live('click', function() {
817     fillInReviewForm();
818     $('#reviewform').contents().find('form').submit();
819   });
820 })();