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