Would be nice if the review-tool offered to show the corresponding header
[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 var CODE_REVIEW_UNITTEST;
25
26 (function() {
27   /**
28    * Create a new function with some of its arguements
29    * pre-filled.
30    * Taken from goog.partial in the Closure library.
31    * @param {Function} fn A function to partially apply.
32    * @param {...*} var_args Additional arguments that are partially
33    *     applied to fn.
34    * @return {!Function} A partially-applied form of the function.
35    */
36   function partial(fn, var_args) {
37     var args = Array.prototype.slice.call(arguments, 1);
38     return function() {
39       // Prepend the bound arguments to the current arguments.
40       var newArgs = Array.prototype.slice.call(arguments);
41       newArgs.unshift.apply(newArgs, args);
42       return fn.apply(this, newArgs);
43     };
44   };
45
46   function determineAttachmentID() {
47     try {
48       return /id=(\d+)/.exec(window.location.search)[1]
49     } catch (ex) {
50       return;
51     }
52   }
53
54   // Attempt to activate only in the "Review Patch" context.
55   if (window.top != window)
56     return;
57
58   if (!CODE_REVIEW_UNITTEST && !window.location.search.match(/action=review/)
59       && !window.location.toString().match(/bugs\.webkit\.org\/PrettyPatch/))
60     return;
61
62   var attachment_id = determineAttachmentID();
63   if (!attachment_id)
64     console.log('No attachment ID');
65
66   var next_line_id = 0;
67   var files = {};
68   var original_file_contents = {};
69   var patched_file_contents = {};
70   var WEBKIT_BASE_DIR = "http://svn.webkit.org/repository/webkit/trunk/";
71   var SIDE_BY_SIDE_DIFFS_KEY = 'sidebysidediffs';
72   var g_displayed_draft_comments = false;
73   var KEY_CODE = {
74     down: 40,
75     enter: 13,
76     escape: 27,
77     j: 74,
78     k: 75,
79     n: 78,
80     p: 80,
81     r: 82,
82     up: 38
83   }
84
85   function idForLine(number) {
86     return 'line' + number;
87   }
88
89   function nextLineID() {
90     return idForLine(next_line_id++);
91   }
92
93   function forEachLine(callback) {
94     for (var i = 0; i < next_line_id; ++i) {
95       callback($('#' + idForLine(i)));
96     }
97   }
98
99   function idify() {
100     this.id = nextLineID();
101   }
102
103   function hoverify() {
104     $(this).hover(function() {
105       $(this).addClass('hot');
106     },
107     function () {
108       $(this).removeClass('hot');
109     });
110   }
111
112   function fileDiffFor(line) {
113     return $(line).parents('.FileDiff');
114   }
115
116   function diffSectionFor(line) {
117     return $(line).parents('.DiffSection');
118   }
119
120   function activeCommentFor(line) {
121     // Scope to the diffSection as a performance improvement.
122     return $('textarea[data-comment-for~="' + line[0].id + '"]', fileDiffFor(line));
123   }
124
125   function previousCommentsFor(line) {
126     // Scope to the diffSection as a performance improvement.
127     return $('div[data-comment-for~="' + line[0].id + '"].previousComment', fileDiffFor(line));
128   }
129
130   function findCommentPositionFor(line) {
131     var previous_comments = previousCommentsFor(line);
132     var num_previous_comments = previous_comments.size();
133     if (num_previous_comments)
134       return $(previous_comments[num_previous_comments - 1])
135     return line;
136   }
137
138   function findCommentBlockFor(line) {
139     var comment_block = findCommentPositionFor(line).next();
140     if (!comment_block.hasClass('comment'))
141       return;
142     return comment_block;
143   }
144
145   function insertCommentFor(line, block) {
146     findCommentPositionFor(line).after(block);
147   }
148
149   function addDraftComment(start_line_id, end_line_id, contents) {
150     var line = $('#' + end_line_id);
151     var start = numberFrom(start_line_id);
152     var end = numberFrom(end_line_id);
153     for (var i = start; i <= end; i++) {
154       addDataCommentBaseLine($('#line' + i), end_line_id);
155     }
156
157     var comment_block = createCommentFor(line);
158     $(comment_block).children('textarea').val(contents);
159     freezeComment(comment_block);
160   }
161
162   function ensureDraftCommentsDisplayed() {
163     if (g_displayed_draft_comments)
164       return;
165     g_displayed_draft_comments = true;
166
167     var comments = g_draftCommentSaver.saved_comments();
168     $(comments.comments).each(function() {
169       addDraftComment(this.start_line_id, this.end_line_id, this.contents);
170     });
171     
172     var overall_comments = comments['overall-comments'];
173     if (overall_comments) {
174       openOverallComments();
175       $('.overallComments textarea').val(overall_comments);
176     }
177   }
178
179   function DraftCommentSaver(opt_attachment_id, opt_localStorage) {
180     this._attachment_id = opt_attachment_id || attachment_id;
181     this._localStorage = opt_localStorage || localStorage;
182     this._save_comments = true;
183   }
184
185   if (CODE_REVIEW_UNITTEST)
186     window['DraftCommentSaver'] = DraftCommentSaver;
187
188   DraftCommentSaver.prototype._json = function() {
189     var comments = $('.comment');
190     var comment_store = [];
191     comments.each(function () {
192       var file_diff = fileDiffFor(this);
193       var textarea = $('textarea', this);
194
195       var contents = textarea.val().trim();
196       if (!contents)
197         return;
198
199       var comment_base_line = textarea.attr('data-comment-for');
200       var lines = contextLinesFor(comment_base_line, file_diff);
201
202       comment_store.push({
203         start_line_id: lines.first().attr('id'),
204         end_line_id: comment_base_line,
205         contents: contents
206       });
207     });
208
209     var overall_comments = $('.overallComments textarea').val().trim();
210     return JSON.stringify({'born-on': Date.now(), 'comments': comment_store, 'overall-comments': overall_comments});
211   }
212   
213   DraftCommentSaver.prototype.saved_comments = function() {
214     var serialized_comments = this._localStorage.getItem(DraftCommentSaver._keyPrefix + this._attachment_id);
215     if (!serialized_comments)
216       return [];
217
218     var comments = {};
219     try {
220       comments = JSON.parse(serialized_comments);
221     } catch (e) {
222       this._erase_corrupt_comments();
223       return {};
224     }
225     
226     var individual_comments = comments.comments;
227     if (comments && !individual_comments.length)
228       return comments;
229     
230     // Sanity check comments are as expected.
231     if (!comments || !individual_comments[0].contents) {
232       this._erase_corrupt_comments();
233       return {};
234     }
235     
236     return comments;
237   }
238   
239   DraftCommentSaver.prototype._erase_corrupt_comments = function() {
240     // FIXME: Show an error to the user instead of logging.
241     console.log('Draft comments were corrupted. Erasing comments.');
242     this.erase();
243   }
244   
245   DraftCommentSaver.prototype.save = function() {
246     if (!this._save_comments)
247       return;
248
249     var key = DraftCommentSaver._keyPrefix + this._attachment_id;
250     var value = this._json();
251
252     if (this._attemptToWrite(key, value))
253       return;
254
255     this._eraseOldCommentsForAllReviews();
256     if (this._attemptToWrite(key, value))
257       return;
258
259     var remove_comments = this._should_remove_comments();
260     if (!remove_comments) {
261       this._save_comments = false;
262       return;
263     }
264
265     this._eraseCommentsForAllReviews();
266     if (this._attemptToWrite(key, value))
267       return;
268
269     this._save_comments = false;
270     // FIXME: Show an error to the user.
271   }
272
273   DraftCommentSaver.prototype._should_remove_comments = function(message) {
274     return prompt('Local storage quota is full. Remove draft comments from all previous reviews to make room?');
275   }
276
277   DraftCommentSaver.prototype._attemptToWrite = function(key, value) {
278     try {
279       this._localStorage.setItem(key, value);
280       return true;
281     } catch (e) {
282       return false;
283     }
284   }
285
286   DraftCommentSaver._keyPrefix = 'draft-comments-for-attachment-';
287
288   DraftCommentSaver.prototype.erase = function() {
289     this._localStorage.removeItem(DraftCommentSaver._keyPrefix + this._attachment_id);
290   }
291
292   DraftCommentSaver.prototype._eraseOldCommentsForAllReviews = function() {
293     this._eraseComments(true);
294   }
295   DraftCommentSaver.prototype._eraseCommentsForAllReviews = function() {
296     this._eraseComments(false);
297   }
298
299   var MONTH_IN_MS = 1000 * 60 * 60 * 24 * 30;
300
301   DraftCommentSaver.prototype._eraseComments = function(only_old_reviews) {
302     var length = this._localStorage.length;
303     var keys_to_delete = [];
304     for (var i = 0; i < length; i++) {
305       var key = this._localStorage.key(i);
306       if (key.indexOf(DraftCommentSaver._keyPrefix) != 0)
307         continue;
308         
309       if (only_old_reviews) {
310         try {
311           var born_on = JSON.parse(this._localStorage.getItem(key))['born-on'];
312           if (Date.now() - born_on < MONTH_IN_MS)
313             continue;
314         } catch (e) {
315           console.log('Deleting JSON. JSON for code review is corrupt: ' + key);
316         }        
317       }
318       keys_to_delete.push(key);
319     }
320
321     for (var i = 0; i < keys_to_delete.length; i++) {
322       this._localStorage.removeItem(keys_to_delete[i]);
323     }
324   }
325   
326   var g_draftCommentSaver = new DraftCommentSaver();
327
328   function saveDraftComments() {
329     ensureDraftCommentsDisplayed();
330     g_draftCommentSaver.save();
331     setAutoSaveStateIndicator('saved');
332   }
333
334   function setAutoSaveStateIndicator(state) {
335     var container = $('.autosave-state');
336     container.text(state);
337     
338     if (state == 'saving')
339       container.addClass(state);
340     else
341       container.removeClass('saving');
342   }
343   
344   function unfreezeCommentFor(line) {
345       // FIXME: This query is overly complex because we place comment blocks
346       // after Lines.  Instead, comment blocks should be children of Lines.
347       findCommentPositionFor(line).next().next().filter('.frozenComment').each(handleUnfreezeComment);
348   }
349
350   function createCommentFor(line) {
351     if (line.attr('data-has-comment')) {
352       unfreezeCommentFor(line);
353       return;
354     }
355     line.attr('data-has-comment', 'true');
356     line.addClass('commentContext');
357
358     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>');
359     $('textarea', comment_block).bind('input', handleOverallCommentsInput);
360     insertCommentFor(line, comment_block);
361     return comment_block;
362   }
363
364   function addCommentFor(line) {
365     var comment_block = createCommentFor(line);
366     if (!comment_block)
367       return;
368
369     comment_block.hide().slideDown('fast', function() {
370       $(this).children('textarea').focus();
371     });
372   }
373
374   function addCommentField(comment_block) {
375     var id = $(comment_block).attr('data-comment-for');
376     if (!id)
377       id = comment_block.id;
378     addCommentFor($('#' + id));
379   }
380   
381   function handleAddCommentField() {
382     addCommentField(this);
383   }
384
385   function addPreviousComment(line, author, comment_text) {
386     var line_id = line.attr('id');
387     var comment_block = $('<div data-comment-for="' + line_id + '" class="previousComment"></div>');
388     var author_block = $('<div class="author"></div>').text(author + ':');
389     var text_block = $('<div class="content"></div>').text(comment_text);
390     comment_block.append(author_block).append(text_block).each(hoverify).click(handleAddCommentField);
391     addDataCommentBaseLine(line, line_id);
392     insertCommentFor(line, comment_block);
393   }
394
395   function displayPreviousComments(comments) {
396     for (var i = 0; i < comments.length; ++i) {
397       var author = comments[i].author;
398       var file_name = comments[i].file_name;
399       var line_number = comments[i].line_number;
400       var comment_text = comments[i].comment_text;
401
402       var file = files[file_name];
403
404       var query = '.Line .to';
405       if (line_number[0] == '-') {
406         // The line_number represent a removal.  We need to adjust the query to
407         // look at the "from" lines.
408         query = '.Line .from';
409         // Trim off the '-' control character.
410         line_number = line_number.substr(1);
411       }
412
413       $(file).find(query).each(function() {
414         if ($(this).text() != line_number)
415           return;
416         var line = $(this).parent();
417         addPreviousComment(line, author, comment_text);
418       });
419     }
420
421     if (comments.length == 0) {
422       return;
423     }
424
425     descriptor = comments.length + ' comment';
426     if (comments.length > 1)
427       descriptor += 's';
428     $('.help .more').before(' This patch has ' + descriptor + '.  Scroll through them with the "n" and "p" keys. ');
429   }
430   
431   function showMoreHelp() {
432     $('.more-help').removeClass('inactive');
433   }
434   
435   function hideMoreHelp() {
436     $('.more-help').addClass('inactive');
437   }
438
439   function scanForStyleQueueComments(text) {
440     var comments = []
441     var lines = text.split('\n');
442     for (var i = 0; i < lines.length; ++i) {
443       var parts = lines[i].match(/^([^:]+):(-?\d+):(.*)$/);
444       if (!parts)
445         continue;
446
447       var file_name = parts[1];
448       var line_number = parts[2];
449       var comment_text = parts[3].trim();
450
451       if (!file_name in files) {
452         console.log('Filename in style queue output is not in the patch: ' + file_name);
453         continue;
454       }
455
456       comments.push({
457         'author': 'StyleQueue',
458         'file_name': file_name,
459         'line_number': line_number,
460         'comment_text': comment_text
461       });
462     }
463     return comments;
464   }
465
466   function scanForComments(author, text) {
467     var comments = []
468     var lines = text.split('\n');
469     for (var i = 0; i < lines.length; ++i) {
470       var parts = lines[i].match(/^([> ]+)([^:]+):(-?\d+)$/);
471       if (!parts)
472         continue;
473       var quote_markers = parts[1];
474       var file_name = parts[2];
475       // FIXME: Store multiple lines for multiline comments and correctly import them here.
476       var line_number = parts[3];
477       if (!file_name in files)
478         continue;
479       while (i < lines.length && lines[i].length > 0 && lines[i][0] == '>')
480         ++i;
481       var comment_lines = [];
482       while (i < lines.length && (lines[i].length == 0 || lines[i][0] != '>')) {
483         comment_lines.push(lines[i]);
484         ++i;
485       }
486       --i; // Decrement i because the for loop will increment it again in a second.
487       var comment_text = comment_lines.join('\n').trim();
488       comments.push({
489         'author': author,
490         'file_name': file_name,
491         'line_number': line_number,
492         'comment_text': comment_text
493       });
494     }
495     return comments;
496   }
497
498   function isReviewFlag(select) {
499     return $(select).attr('title') == 'Request for patch review.';
500   }
501
502   function isCommitQueueFlag(select) {
503     return $(select).attr('title').match(/commit-queue/);
504   }
505
506   function findControlForFlag(select) {
507     if (isReviewFlag(select))
508       return $('#toolbar .review select');
509     else if (isCommitQueueFlag(select))
510       return $('#toolbar .commitQueue select');
511     return $();
512   }
513
514   function addFlagsForAttachment(details) {
515     var flag_control = "<select><option></option><option>?</option><option>+</option><option>-</option></select>";
516     $('#flagContainer').append(
517       $('<span class="review"> r: ' + flag_control + '</span>')).append(
518       $('<span class="commitQueue"> cq: ' + flag_control + '</span>'));
519
520     details.find('#flags select').each(function() {
521       var requestee = $(this).parent().siblings('td:first-child').text().trim();
522       if (requestee.length) {
523         // Remove trailing ':'.
524         requestee = requestee.substr(0, requestee.length - 1);
525         requestee = ' (' + requestee + ')';
526       }
527       var control = findControlForFlag(this)
528       control.attr('selectedIndex', $(this).attr('selectedIndex'));
529       control.parent().prepend(requestee);
530     });
531   }
532
533   window.addEventListener('message', function(e) {
534     if (e.origin != 'https://webkit-commit-queue.appspot.com')
535       return;
536
537     if (e.data.height) {
538       $('.statusBubble')[0].style.height = e.data.height;
539       $('.statusBubble')[0].style.width = e.data.width;
540     }
541   }, false);
542
543   function handleStatusBubbleLoad(e) {
544     e.target.contentWindow.postMessage('containerMetrics', 'https://webkit-commit-queue.appspot.com');
545   }
546
547   function fetchHistory() {
548     $.get('attachment.cgi?id=' + attachment_id + '&action=edit', function(data) {
549       var bug_id = /Attachment \d+ Details for Bug (\d+)/.exec(data)[1];
550       $.get('show_bug.cgi?id=' + bug_id, function(data) {
551         var comments = [];
552         $(data).find('.bz_comment').each(function() {
553           var author = $(this).find('.email').text();
554           var text = $(this).find('.bz_comment_text').text();
555
556           var comment_marker = '(From update of attachment ' + attachment_id + ' .details.)';
557           if (text.match(comment_marker))
558             $.merge(comments, scanForComments(author, text));
559
560           var style_queue_comment_marker = 'Attachment ' + attachment_id + ' .details. did not pass style-queue.'
561           if (text.match(style_queue_comment_marker))
562             $.merge(comments, scanForStyleQueueComments(text));
563         });
564         displayPreviousComments(comments);
565         ensureDraftCommentsDisplayed();
566       });
567
568       var details = $(data);
569       addFlagsForAttachment(details);
570
571       var statusBubble = document.createElement('iframe');
572       statusBubble.className = 'statusBubble';
573       statusBubble.src  = 'https://webkit-commit-queue.appspot.com/status-bubble/' + attachment_id;
574       statusBubble.scrolling = 'no';
575       // Can't append the HTML because we need to set the onload handler before appending the iframe to the DOM.
576       statusBubble.onload = handleStatusBubbleLoad;
577       $('#statusBubbleContainer').append(statusBubble);
578
579       $('#toolbar .bugLink').html('<a href="/show_bug.cgi?id=' + bug_id + '" target="_blank">Bug ' + bug_id + '</a>');
580     });
581   }
582
583   function firstLine(file_diff) {
584     var container = $('.LineContainer:not(.context)', file_diff)[0];
585     if (!container)
586       return 0;
587
588     var from = fromLineNumber(container);
589     var to = toLineNumber(container);
590     return from || to;
591   }
592
593   function crawlDiff() {
594     $('.Line').each(idify).each(hoverify);
595     $('.FileDiff').each(function() {
596       var header = $(this).children('h1');
597       var url_hash = '#L' + firstLine(this);
598
599       var file_name = header.text();
600       files[file_name] = this;
601
602       addExpandLinks(file_name);
603
604       var diff_links = $('<div class="FileDiffLinkContainer LinkContainer">' +
605           diffLinksHtml() +
606           '</div>');
607
608       var file_link = $('a', header)[0];
609       // If the base directory in the file path does not match a WebKit top level directory,
610       // then PrettyPatch.rb doesn't linkify the header.
611       if (file_link) {
612         file_link.target = "_blank";
613         file_link.href += url_hash;
614         diff_links.append(tracLinks(file_name, url_hash));
615       }
616
617       $('h1', this).after(diff_links);
618       updateDiffLinkVisibility(this);
619     });
620   }
621
622   function tracLinks(file_name, url_hash) {
623     var trac_links = $('<a target="_blank">annotate</a><a target="_blank">revision log</a>');
624     trac_links[0].href = 'http://trac.webkit.org/browser/trunk/' + file_name + '?annotate=blame' + url_hash;
625     trac_links[1].href = 'http://trac.webkit.org/log/trunk/' + file_name;
626     var implementation_suffix_list = ['.cpp', '.mm'];
627     for (var i = 0; i < implementation_suffix_list.length; ++i) {
628       var suffix = implementation_suffix_list[i];
629       if (file_name.lastIndexOf(suffix) == file_name.length - suffix.length) {
630         trac_links.prepend('<a target="_blank">header</a>');
631         var stem = file_name.substr(0, file_name.length - suffix.length);
632         trac_links[0].href= 'http://trac.webkit.org/log/trunk/' + stem + '.h';
633       }
634     }
635     return trac_links;
636   }
637
638   if (CODE_REVIEW_UNITTEST)
639     window.tracLinks = tracLinks;
640
641   function addExpandLinks(file_name) {
642     if (file_name.indexOf('ChangeLog') != -1)
643       return;
644
645     var file_diff = files[file_name];
646
647     // Don't show the links to expand upwards/downwards if the patch starts/ends without context
648     // lines, i.e. starts/ends with add/remove lines.
649     var first_line = file_diff.querySelector('.LineContainer:not(.context)');
650
651     // If there is no element with a "Line" class, then this is an image diff.
652     if (!first_line)
653       return;
654
655     var expand_bar_index = 0;
656     if (!$(first_line).hasClass('add') && !$(first_line).hasClass('remove'))
657       $('h1', file_diff).after(expandBarHtml(BELOW))
658
659     $('br', file_diff).replaceWith(expandBarHtml());
660
661     // jquery doesn't support :last-of-type, so use querySelector instead.
662     var last_line = file_diff.querySelector('.LineContainer:last-of-type');
663     // Some patches for new files somehow end up with an empty context line at the end
664     // with a from line number of 0. Don't show expand links in that case either.
665     if (!$(last_line).hasClass('add') && !$(last_line).hasClass('remove') && fromLineNumber(last_line) != 0)
666       $(file_diff.querySelector('.DiffSection:last-of-type')).after(expandBarHtml(ABOVE));
667   }
668
669   function expandBarHtml(opt_direction) {
670     var html = '<div class="ExpandBar">' +
671         '<div class="ExpandArea Expand' + ABOVE + '"></div>' +
672         '<div class="ExpandLinkContainer LinkContainer"><span class="ExpandText">expand: </span>';
673
674     // FIXME: If there are <100 line to expand, don't show the expand-100 link.
675     // If there are <20 lines to expand, don't show the expand-20 link.
676     if (!opt_direction || opt_direction == ABOVE) {
677       html += expandLinkHtml(ABOVE, 100) +
678           expandLinkHtml(ABOVE, 20);
679     }
680
681     html += expandLinkHtml(ALL);
682
683     if (!opt_direction || opt_direction == BELOW) {
684       html += expandLinkHtml(BELOW, 20) +
685         expandLinkHtml(BELOW, 100);
686     }
687
688     html += '</div><div class="ExpandArea Expand' + BELOW + '"></div></div>';
689     return html;
690   }
691
692   function expandLinkHtml(direction, amount) {
693     return "<a class='ExpandLink' href='javascript:' data-direction='" + direction + "' data-amount='" + amount + "'>" +
694         (amount ? amount + " " : "") + direction + "</a>";
695   }
696
697   function handleExpandLinkClick() {
698     var expand_bar = $(this).parents('.ExpandBar');
699     var file_name = expand_bar.parents('.FileDiff').children('h1')[0].textContent;
700     var expand_function = partial(expand, expand_bar[0], file_name, this.getAttribute('data-direction'), Number(this.getAttribute('data-amount')));
701     if (file_name in original_file_contents)
702       expand_function();
703     else
704       getWebKitSourceFile(file_name, expand_function, expand_bar);
705   }
706
707   function handleSideBySideLinkClick() {
708     convertDiff('sidebyside', this);
709   }
710
711   function handleUnifyLinkClick() {
712     convertDiff('unified', this);
713   }
714
715   function convertDiff(difftype, convert_link) {
716     var file_diffs = $(convert_link).parents('.FileDiff');
717     if (!file_diffs.size()) {
718       localStorage.setItem('code-review-diffstate', difftype);
719       file_diffs = $('.FileDiff');
720     }
721
722     convertAllFileDiffs(difftype, file_diffs);
723   }
724
725   function patchRevision() {
726     var revision = $('.revision');
727     return revision[0] ? revision.first().text() : null;
728   }
729
730   function getWebKitSourceFile(file_name, onLoad, expand_bar) {
731     function handleLoad(contents) {
732       original_file_contents[file_name] = contents.split('\n');
733       patched_file_contents[file_name] = applyDiff(original_file_contents[file_name], file_name);
734       onLoad();
735     };
736
737     var revision = patchRevision();
738     var queryParameters = revision ? '?p=' + revision : '';
739
740     $.ajax({
741       url: WEBKIT_BASE_DIR + file_name + queryParameters,
742       context: document.body,
743       complete: function(xhr, data) {
744               if (xhr.status == 0)
745                   handleLoadError(expand_bar);
746               else
747                   handleLoad(xhr.responseText);
748       }
749     });
750   }
751
752   function replaceExpandLinkContainers(expand_bar, text) {
753     $('.ExpandLinkContainer', $(expand_bar).parents('.FileDiff')).replaceWith('<span class="ExpandText">' + text + '</span>');
754   }
755
756   function handleLoadError(expand_bar) {
757     replaceExpandLinkContainers(expand_bar, "Can't expand. Is this a new or deleted file?");
758   }
759
760   var ABOVE = 'above';
761   var BELOW = 'below';
762   var ALL = 'all';
763
764   function lineNumbersFromSet(set, is_last) {
765     var to = -1;
766     var from = -1;
767
768     var size = set.size();
769     var start = is_last ? (size - 1) : 0;
770     var end = is_last ? -1 : size;
771     var offset = is_last ? -1 : 1;
772
773     for (var i = start; i != end; i += offset) {
774       if (to != -1 && from != -1)
775         return {to: to, from: from};
776
777       var line_number = set[i];
778       if ($(line_number).hasClass('to')) {
779         if (to == -1)
780           to = Number(line_number.textContent);
781       } else {
782         if (from == -1)
783           from = Number(line_number.textContent);
784       }
785     }
786   }
787
788   function removeContextBarBelow(expand_bar) {
789     $('.context', expand_bar.nextElementSibling).detach();
790   }
791
792   function expand(expand_bar, file_name, direction, amount) {
793     if (file_name in original_file_contents && !patched_file_contents[file_name]) {
794       // FIXME: In this case, try fetching the source file at the revision the patch was created at.
795       // Might need to modify webkit-patch to include that data in the diff.
796       replaceExpandLinkContainers(expand_bar, "Can't expand. Unable to apply patch to tip of tree.");
797       return;
798     }
799
800     var above_expansion = expand_bar.querySelector('.Expand' + ABOVE)
801     var below_expansion = expand_bar.querySelector('.Expand' + BELOW)
802
803     var above_line_numbers = $('.expansionLineNumber', above_expansion);
804     if (!above_line_numbers[0]) {
805       var diff_section = expand_bar.previousElementSibling;
806       above_line_numbers = $('.Line:not(.context) .lineNumber', diff_section);
807     }
808
809     var above_last_line_num, above_last_from_line_num;
810     if (above_line_numbers[0]) {
811       var above_numbers = lineNumbersFromSet(above_line_numbers, true);
812       above_last_line_num = above_numbers.to;
813       above_last_from_line_num = above_numbers.from;
814     } else
815       above_last_from_line_num = above_last_line_num = 0;
816
817     var below_line_numbers = $('.expansionLineNumber', below_expansion);
818     if (!below_line_numbers[0]) {
819       var diff_section = expand_bar.nextElementSibling;
820       if (diff_section)
821         below_line_numbers = $('.Line:not(.context) .lineNumber', diff_section);
822     }
823
824     var below_first_line_num, below_first_from_line_num;
825     if (below_line_numbers[0]) {
826       var below_numbers = lineNumbersFromSet(below_line_numbers, false);
827       below_first_line_num = below_numbers.to - 1;
828       below_first_from_line_num = below_numbers.from - 1;
829     } else
830       below_first_from_line_num = below_first_line_num = patched_file_contents[file_name].length - 1;
831
832     var start_line_num, start_from_line_num;
833     var end_line_num;
834
835     if (direction == ABOVE) {
836       start_from_line_num = above_last_from_line_num;
837       start_line_num = above_last_line_num;
838       end_line_num = Math.min(start_line_num + amount, below_first_line_num);
839     } else if (direction == BELOW) {
840       end_line_num = below_first_line_num;
841       start_line_num = Math.max(end_line_num - amount, above_last_line_num)
842       start_from_line_num = Math.max(below_first_from_line_num - amount, above_last_from_line_num)
843     } else { // direction == ALL
844       start_line_num = above_last_line_num;
845       start_from_line_num = above_last_from_line_num;
846       end_line_num = below_first_line_num;
847     }
848
849     var lines = expansionLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num);
850
851     var expansion_area;
852     // Filling in all the remaining lines. Overwrite the expand links.
853     if (start_line_num == above_last_line_num && end_line_num == below_first_line_num) {
854       $('.ExpandLinkContainer', expand_bar).detach();
855       below_expansion.insertBefore(lines, below_expansion.firstChild);
856       removeContextBarBelow(expand_bar);
857     } else if (direction == ABOVE) {
858       above_expansion.appendChild(lines);
859     } else {
860       below_expansion.insertBefore(lines, below_expansion.firstChild);
861       removeContextBarBelow(expand_bar);
862     }
863   }
864
865   function unifiedLine(from, to, contents, is_expansion_line, opt_className, opt_attributes) {
866     var className = is_expansion_line ? 'ExpansionLine' : 'LineContainer Line';
867     if (opt_className)
868       className += ' ' + opt_className;
869
870     var lineNumberClassName = is_expansion_line ? 'expansionLineNumber' : 'lineNumber';
871
872     var line = $('<div class="' + className + '" ' + (opt_attributes || '') + '>' +
873         '<span class="from ' + lineNumberClassName + '">' + (from || '&nbsp;') +
874         '</span><span class="to ' + lineNumberClassName + '">' + (to || '&nbsp;') +
875         '</span><span class="text"></span>' +
876         '</div>');
877
878     $('.text', line).replaceWith(contents);
879     return line;
880   }
881
882   function unifiedExpansionLine(from, to, contents) {
883     return unifiedLine(from, to, contents, true);
884   }
885
886   function sideBySideExpansionLine(from, to, contents) {
887     var line = $('<div class="ExpansionLine"></div>');
888     // Clone the contents so we have two copies we can put back in the DOM.
889     line.append(lineSide('from', contents.clone(true), true, from));
890     line.append(lineSide('to', contents, true, to));
891     return line;
892   }
893
894   function lineSide(side, contents, is_expansion_line, opt_line_number, opt_attributes, opt_class) {
895     var class_name = '';
896     if (opt_attributes || opt_class) {
897       class_name = 'class="';
898       if (opt_attributes)
899         class_name += is_expansion_line ? 'ExpansionLine' : 'Line';
900       class_name += ' ' + (opt_class || '') + '"';
901     }
902
903     var attributes = opt_attributes || '';
904
905     var line_side = $('<div class="LineSide">' +
906         '<div ' + attributes + ' ' + class_name + '>' +
907           '<span class="' + side + ' ' + (is_expansion_line ? 'expansionLineNumber' : 'lineNumber') + '">' +
908               (opt_line_number || '&nbsp;') +
909           '</span>' +
910           '<span class="text"></span>' +
911         '</div>' +
912         '</div>');
913
914     $('.text', line_side).replaceWith(contents);
915     return line_side;
916   }
917
918   function expansionLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num) {
919     var fragment = document.createDocumentFragment();
920     var is_side_by_side = isDiffSideBySide(files[file_name]);
921
922     for (var i = 0; i < end_line_num - start_line_num; i++) {
923       var from = start_from_line_num + i + 1;
924       var to = start_line_num + i + 1;
925       var contents = $('<span class="text"></span>');
926       contents.text(patched_file_contents[file_name][start_line_num + i]);
927       var line = is_side_by_side ? sideBySideExpansionLine(from, to, contents) : unifiedExpansionLine(from, to, contents);
928       fragment.appendChild(line[0]);
929     }
930
931     return fragment;
932   }
933
934   function hunkStartingLine(patched_file, context, prev_line, hunk_num) {
935     var current_line = -1;
936     var last_context_line = context[context.length - 1];
937     if (patched_file[prev_line] == last_context_line)
938       current_line = prev_line + 1;
939     else {
940       console.log('Hunk #' + hunk_num + ' FAILED.');
941       return -1;
942     }
943
944     // For paranoia sake, confirm the rest of the context matches;
945     for (var i = 0; i < context.length - 1; i++) {
946       if (patched_file[current_line - context.length + i] != context[i]) {
947         console.log('Hunk #' + hunk_num + ' FAILED. Did not match preceding context.');
948         return -1;
949       }
950     }
951
952     return current_line;
953   }
954
955   function fromLineNumber(line) {
956     var node = line.querySelector('.from');
957     return node ? Number(node.textContent) : 0;
958   }
959
960   function toLineNumber(line) {
961     var node = line.querySelector('.to');
962     return node ? Number(node.textContent) : 0;
963   }
964
965   function textContentsFor(line) {
966     // Just get the first match since a side-by-side diff has two lines with text inside them for
967     // unmodified lines in the diff.
968     return $('.text', line).first().text();
969   }
970
971   function lineNumberForFirstNonContextLine(patched_file, line, prev_line, context, hunk_num) {
972     if (context.length) {
973       var prev_line_num = fromLineNumber(prev_line) - 1;
974       return hunkStartingLine(patched_file, context, prev_line_num, hunk_num);
975     }
976
977     if (toLineNumber(line) == 1 || fromLineNumber(line) == 1)
978       return 0;
979
980     console.log('Failed to apply patch. Adds or removes lines before any context lines.');
981     return -1;
982   }
983
984   function applyDiff(original_file, file_name) {
985     var diff_sections = files[file_name].getElementsByClassName('DiffSection');
986     var patched_file = original_file.concat([]);
987
988     // Apply diffs in reverse order to avoid needing to keep track of changing line numbers.
989     for (var i = diff_sections.length - 1; i >= 0; i--) {
990       var section = diff_sections[i];
991       var lines = $('.Line:not(.context)', section);
992       var current_line = -1;
993       var context = [];
994       var hunk_num = i + 1;
995
996       for (var j = 0, lines_len = lines.length; j < lines_len; j++) {
997         var line = lines[j];
998         var line_contents = textContentsFor(line);
999         if ($(line).hasClass('add')) {
1000           if (current_line == -1) {
1001             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
1002             if (current_line == -1)
1003               return null;
1004           }
1005
1006           patched_file.splice(current_line, 0, line_contents);
1007           current_line++;
1008         } else if ($(line).hasClass('remove')) {
1009           if (current_line == -1) {
1010             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
1011             if (current_line == -1)
1012               return null;
1013           }
1014
1015           if (patched_file[current_line] != line_contents) {
1016             console.log('Hunk #' + hunk_num + ' FAILED.');
1017             return null;
1018           }
1019
1020           patched_file.splice(current_line, 1);
1021         } else if (current_line == -1) {
1022           context.push(line_contents);
1023         } else if (line_contents != patched_file[current_line]) {
1024           console.log('Hunk #' + hunk_num + ' FAILED. Context at end did not match');
1025           return null;
1026         } else {
1027           current_line++;
1028         }
1029       }
1030     }
1031
1032     return patched_file;
1033   }
1034
1035   function openOverallComments(e) {
1036     $('.overallComments textarea').addClass('open');
1037     $('#statusBubbleContainer').addClass('wrap');
1038   }
1039
1040   var g_overallCommentsInputTimer;
1041
1042   function handleOverallCommentsInput() {
1043     setAutoSaveStateIndicator('saving');
1044     // Save draft comments after we haven't received an input event in 1 second.
1045     if (g_overallCommentsInputTimer)
1046       clearTimeout(g_overallCommentsInputTimer);
1047     g_overallCommentsInputTimer = setTimeout(saveDraftComments, 1000);
1048   }
1049
1050   function onBodyResize() {
1051     updateToolbarAnchorState();
1052   }
1053
1054   function updateToolbarAnchorState() {
1055     // For iPad, we always leave the toolbar at the bottom of the document
1056     // because of the iPad's handling of position:fixed and scrolling.
1057     if (navigator.platform.indexOf("iPad") != -1)
1058       return;
1059
1060     var toolbar = $('#toolbar');
1061     // Unanchor the toolbar and then see if it's bottom is below the body's bottom.
1062     toolbar.toggleClass('anchored', false);
1063     var toolbar_bottom = toolbar.offset().top + toolbar.outerHeight();
1064     var should_anchor = toolbar_bottom >= document.body.clientHeight;
1065     toolbar.toggleClass('anchored', should_anchor);
1066   }
1067
1068   function diffLinksHtml() {
1069     return '<a href="javascript:" class="unify-link">unified</a>' +
1070       '<a href="javascript:" class="side-by-side-link">side-by-side</a>';
1071   }
1072
1073   $(document).ready(function() {
1074     if (CODE_REVIEW_UNITTEST)
1075       return;
1076     crawlDiff();
1077     fetchHistory();
1078     $(document.body).prepend('<div id="message">' +
1079         '<div class="help">Select line numbers to add a comment. Scroll though diffs with the "j" and "k" keys.' +
1080           '<div class="DiffLinks LinkContainer">' + diffLinksHtml() + '</div>' +
1081           '<a href="javascript:" class="more">[more]</a>' +
1082           '<div class="more-help inactive">' +
1083             '<div class="winter"></div>' +
1084             '<div class="lightbox"><table>' +
1085               '<tr><td>enter</td><td>add/edit comment for focused item</td></tr>' +
1086               '<tr><td>escape</td><td>accept current comment / close preview and help popups</td></tr>' +
1087               '<tr><td>j</td><td>focus next diff</td></tr>' +
1088               '<tr><td>k</td><td>focus previous diff</td></tr>' +
1089               '<tr><td>shift + j</td><td>focus next line</td></tr>' +
1090               '<tr><td>shift + k</td><td>focus previous line</td></tr>' +
1091               '<tr><td>n</td><td>focus next comment</td></tr>' +
1092               '<tr><td>p</td><td>focus previous comment</td></tr>' +
1093               '<tr><td>r</td><td>focus review select element</td></tr>' +
1094               '<tr><td>ctrl + shift + up</td><td>extend context of the focused comment</td></tr>' +
1095               '<tr><td>ctrl + shift + down</td><td>shrink context of the focused comment</td></tr>' +
1096             '</table></div>' +
1097           '</div>' +
1098         '</div>' +
1099         '</div>');
1100     $(document.body).append('<div id="toolbar">' +
1101         '<div class="overallComments">' +
1102             '<textarea placeholder="Overall comments"></textarea>' +
1103         '</div>' +
1104         '<div>' +
1105           '<span id="statusBubbleContainer"></span>' +
1106           '<span class="actions">' +
1107               '<span class="links"><span class="bugLink"></span></span>' +
1108               '<span id="flagContainer"></span>' +
1109               '<button id="preview_comments">Preview</button>' +
1110               '<button id="post_comments">Publish</button> ' +
1111           '</span>' +
1112         '</div>' +
1113         '<div class="autosave-state"></div>' +
1114         '</div>');
1115
1116     $('.overallComments textarea').bind('click', openOverallComments);
1117     $('.overallComments textarea').bind('input', handleOverallCommentsInput);
1118
1119     $(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>');
1120     $('#reviewform').bind('load', handleReviewFormLoad);
1121
1122     // Create a dummy iframe and monitor resizes in it's contentWindow to detect when the top document's body changes size.
1123     // FIXME: Should we setTimeout throttle these?
1124     var resize_iframe = $('<iframe class="pseudo_resize_event_iframe"></iframe>');
1125     $(document.body).append(resize_iframe);
1126     // Handle the event on a timeout to avoid crashing Firefox.
1127     $(resize_iframe[0].contentWindow).bind('resize', function() { setTimeout(onBodyResize, 0)});
1128
1129     updateToolbarAnchorState();
1130     loadDiffState();
1131   });
1132
1133   function handleReviewFormLoad() {
1134     var review_form_contents = $('#reviewform').contents();
1135     if (review_form_contents[0].querySelector('#form-controls #flags')) {
1136       review_form_contents.bind('keydown', function(e) {
1137         if (e.keyCode == KEY_CODE.escape)
1138           hideCommentForm();
1139       });
1140
1141       // This is the intial load of the review form iframe.
1142       var form = review_form_contents.find('form')[0];
1143       form.addEventListener('submit', eraseDraftComments);
1144       form.target = '';
1145       return;
1146     }
1147
1148     // Review form iframe have the publish button has been pressed.
1149     var email_sent_to = review_form_contents[0].querySelector('#bugzilla-body dl');
1150     // If the email_send_to DL is not in the tree that means the publish failed for some reason,
1151     // e.g., you're not logged in. Show the comment form to allow you to login.
1152     if (!email_sent_to) {
1153       showCommentForm();
1154       return;
1155     }
1156
1157     eraseDraftComments();
1158     // FIXME: Once WebKit supports seamless iframes, we can just make the review-form
1159     // iframe fill the page instead of redirecting back to the bug.
1160     window.location.replace($('#toolbar .bugLink a').attr('href'));
1161   }
1162   
1163   function eraseDraftComments() {
1164     g_draftCommentSaver.erase();
1165   }
1166
1167   function loadDiffState() {
1168     var diffstate = localStorage.getItem('code-review-diffstate');
1169     if (diffstate != 'sidebyside' && diffstate != 'unified')
1170       return;
1171
1172     convertAllFileDiffs(diffstate, $('.FileDiff'));
1173   }
1174
1175   function isDiffSideBySide(file_diff) {
1176     return diffState(file_diff) == 'sidebyside';
1177   }
1178
1179   function diffState(file_diff) {
1180     var diff_state = $(file_diff).attr('data-diffstate');
1181     return diff_state || 'unified';
1182   }
1183
1184   function unifyLine(line, from, to, contents, classNames, attributes, id) {
1185     var new_line = unifiedLine(from, to, contents, false, classNames, attributes);
1186     var old_line = $(line);
1187     if (!old_line.hasClass('LineContainer'))
1188       old_line = old_line.parents('.LineContainer');
1189
1190     var comments = commentsToTransferFor($(document.getElementById(id)));
1191     old_line.after(comments);
1192     old_line.replaceWith(new_line);
1193   }
1194
1195   function updateDiffLinkVisibility(file_diff) {
1196     if (diffState(file_diff) == 'unified') {
1197       $('.side-by-side-link', file_diff).show();
1198       $('.unify-link', file_diff).hide();
1199     } else {
1200       $('.side-by-side-link', file_diff).hide();
1201       $('.unify-link', file_diff).show();
1202     }
1203   }
1204
1205   function convertAllFileDiffs(diff_type, file_diffs) {
1206     file_diffs.each(function() {
1207       convertFileDiff(diff_type, this);
1208     });
1209   }
1210
1211   function convertFileDiff(diff_type, file_diff) {
1212     if (diffState(file_diff) == diff_type)
1213       return;
1214
1215     $(file_diff).removeClass('sidebyside unified');
1216     $(file_diff).addClass(diff_type);
1217
1218     $(file_diff).attr('data-diffstate', diff_type);
1219     updateDiffLinkVisibility(file_diff);
1220
1221     $('.context', file_diff).each(function() {
1222       convertLine(diff_type, this);
1223     });
1224
1225     $('.shared .Line', file_diff).each(function() {
1226       convertLine(diff_type, this);
1227     });
1228
1229     $('.ExpansionLine', file_diff).each(function() {
1230       convertExpansionLine(diff_type, this);
1231     });
1232   }
1233
1234   function convertLine(diff_type, line) {
1235     var convert_function = diff_type == 'sidebyside' ? sideBySideifyLine : unifyLine;
1236     var from = fromLineNumber(line);
1237     var to = toLineNumber(line);
1238     var contents = $('.text', line).first();
1239     var classNames = classNamesForMovingLine(line);
1240     var attributes = attributesForMovingLine(line);
1241     var id = line.id;
1242     convert_function(line, from, to, contents, classNames, attributes, id)
1243   }
1244
1245   function classNamesForMovingLine(line) {
1246     var classParts = line.className.split(' ');
1247     var classBuffer = [];
1248     for (var i = 0; i < classParts.length; i++) {
1249       var part = classParts[i];
1250       if (part != 'LineContainer' && part != 'Line')
1251         classBuffer.push(part);
1252     }
1253     return classBuffer.join(' ');
1254   }
1255
1256   function attributesForMovingLine(line) {
1257     var attributesBuffer = ['id=' + line.id];
1258     // Make sure to keep all data- attributes.
1259     $(line.attributes).each(function() {
1260       if (this.name.indexOf('data-') == 0)
1261         attributesBuffer.push(this.name + '=' + this.value);
1262     });
1263     return attributesBuffer.join(' ');
1264   }
1265
1266   function sideBySideifyLine(line, from, to, contents, classNames, attributes, id) {
1267     var from_class = '';
1268     var to_class = '';
1269     var from_attributes = '';
1270     var to_attributes = '';
1271     // Clone the contents so we have two copies we can put back in the DOM.
1272     var from_contents = contents.clone(true);
1273     var to_contents = contents;
1274
1275     var container_class = 'LineContainer';
1276     var container_attributes = '';
1277
1278     if (from && !to) { // This is a remove line.
1279       from_class = classNames;
1280       from_attributes = attributes;
1281       to_contents = '';
1282     } else if (to && !from) { // This is an add line.
1283       to_class = classNames;
1284       to_attributes = attributes;
1285       from_contents = '';
1286     } else {
1287       container_attributes = attributes;
1288       container_class += ' Line ' + classNames;
1289     }
1290
1291     var new_line = $('<div ' + container_attributes + ' class="' + container_class + '"></div>');
1292     new_line.append(lineSide('from', from_contents, false, from, from_attributes, from_class));
1293     new_line.append(lineSide('to', to_contents, false, to, to_attributes, to_class));
1294
1295     $(line).replaceWith(new_line);
1296
1297     var line = $(document.getElementById(id));
1298     line.after(commentsToTransferFor(line));
1299   }
1300
1301   function convertExpansionLine(diff_type, line) {
1302     var convert_function = diff_type == 'sidebyside' ? sideBySideExpansionLine : unifiedExpansionLine;
1303     var contents = $('.text', line).first();
1304     var from = fromLineNumber(line);
1305     var to = toLineNumber(line);
1306     var new_line = convert_function(from, to, contents);
1307     $(line).replaceWith(new_line);
1308   }
1309
1310   function commentsToTransferFor(line) {
1311     var fragment = document.createDocumentFragment();
1312
1313     previousCommentsFor(line).each(function() {
1314       fragment.appendChild(this);
1315     });
1316
1317     var active_comments = activeCommentFor(line);
1318     var num_active_comments = active_comments.size();
1319     if (num_active_comments > 0) {
1320       if (num_active_comments > 1)
1321         console.log('ERROR: There is more than one active comment for ' + line.attr('id') + '.');
1322
1323       var parent = active_comments[0].parentNode;
1324       var frozenComment = parent.nextSibling;
1325       fragment.appendChild(parent);
1326       fragment.appendChild(frozenComment);
1327     }
1328
1329     return fragment;
1330   }
1331
1332   function discardComment(comment_block) {
1333     var line_id = comment_block.find('textarea').attr('data-comment-for');
1334     var line = $('#' + line_id)
1335     findCommentBlockFor(line).slideUp('fast', function() {
1336       $(this).remove();
1337       line.removeAttr('data-has-comment');
1338       trimCommentContextToBefore(line, line.attr('data-comment-base-line'));
1339       saveDraftComments();
1340     });
1341   }
1342
1343   function handleUnfreezeComment() {
1344     unfreezeComment(this);
1345   }
1346
1347   function unfreezeComment(comment) {
1348     var unfrozen_comment = $(comment).prev();
1349     unfrozen_comment.show();
1350     $(comment).remove();
1351     unfrozen_comment.find('textarea')[0].focus();
1352   }
1353
1354   function showFileDiffLinks() {
1355     $('.LinkContainer', this).each(function() { this.style.opacity = 1; });
1356   }
1357
1358   function hideFileDiffLinks() {
1359     $('.LinkContainer', this).each(function() { this.style.opacity = 0; });
1360   }
1361
1362   function handleDiscardComment() {
1363     discardComment($(this).parents('.comment'));
1364   }
1365   
1366   function handleAcceptComment() {
1367     acceptComment($(this).parents('.comment'));
1368   }
1369   
1370   function acceptComment(comment) {
1371     var frozen_comment = freezeComment(comment);
1372     focusOn(frozen_comment);
1373     saveDraftComments();
1374   }
1375
1376   $('.FileDiff').live('mouseenter', showFileDiffLinks);
1377   $('.FileDiff').live('mouseleave', hideFileDiffLinks);
1378   $('.side-by-side-link').live('click', handleSideBySideLinkClick);
1379   $('.unify-link').live('click', handleUnifyLinkClick);
1380   $('.ExpandLink').live('click', handleExpandLinkClick);
1381   $('.frozenComment').live('click', handleUnfreezeComment);
1382   $('.comment .discard').live('click', handleDiscardComment);
1383   $('.comment .ok').live('click', handleAcceptComment);
1384   $('.more').live('click', showMoreHelp);
1385   $('.more-help .winter').live('click', hideMoreHelp);
1386
1387   function freezeComment(comment_block) {
1388     var comment_textarea = comment_block.find('textarea');
1389     if (comment_textarea.val().trim() == '') {
1390       discardComment(comment_block);
1391       return;
1392     }
1393     var line_id = comment_textarea.attr('data-comment-for');
1394     var line = $('#' + line_id)
1395     var frozen_comment = $('<div class="frozenComment"></div>').text(comment_textarea.val());
1396     findCommentBlockFor(line).hide().after(frozen_comment);
1397     return frozen_comment;
1398   }
1399
1400   function focusOn(node, opt_is_backward) {
1401     if (node.length == 0)
1402       return;
1403
1404     // Give a tabindex so the element can receive actual browser focus.
1405     // -1 makes the element focusable without actually putting in in the tab order.
1406     node.attr('tabindex', -1);
1407     node.focus();
1408     // Remove the tabindex on blur to avoid having the node be mouse-focusable.
1409     node.bind('blur', function() { node.removeAttr('tabindex'); });
1410     
1411     var node_top = node.offset().top;
1412     var is_top_offscreen = node_top <= $(document).scrollTop();
1413     
1414     var half_way_point = $(document).scrollTop() + window.innerHeight / 2;
1415     var is_top_past_halfway = opt_is_backward ? node_top < half_way_point : node_top > half_way_point;
1416
1417     if (is_top_offscreen || is_top_past_halfway)
1418       $(document).scrollTop(node_top - window.innerHeight / 2);
1419   }
1420
1421   function visibleNodeFilterFunction(is_backward) {
1422     var y = is_backward ? $('#toolbar')[0].offsetTop - 1 : 0;
1423     var x = window.innerWidth / 2;
1424     var reference_element = document.elementFromPoint(x, y);
1425
1426     if (reference_element.nodeName == 'HTML' || reference_element.nodeName == 'BODY') {
1427       // In case we hit test a margin between file diffs, shift a fudge factor and try again.
1428       // FIXME: Is there a better way to do this?
1429       var file_diffs = $('.FileDiff');
1430       var first_diff = file_diffs.first();
1431       var second_diff = $(file_diffs[1]);
1432       var distance_between_file_diffs = second_diff.position().top - first_diff.position().top - first_diff.height();
1433
1434       if (is_backward)
1435         y -= distance_between_file_diffs;
1436       else
1437         y += distance_between_file_diffs;
1438
1439       reference_element = document.elementFromPoint(x, y);
1440     }
1441
1442     if (reference_element.nodeName == 'HTML' || reference_element.nodeName == 'BODY')
1443       return null;
1444     
1445     return function(node) {
1446       var compare = reference_element.compareDocumentPosition(node[0]);
1447       if (is_backward)
1448         return compare & Node.DOCUMENT_POSITION_PRECEDING;
1449       return compare & Node.DOCUMENT_POSITION_FOLLOWING;
1450     }
1451   }
1452
1453   function focusNext(filter, direction) {
1454     var focusable_nodes = $('a,.Line,.frozenComment,.previousComment,.DiffBlock,.overallComments').filter(function() {
1455       return !$(this).hasClass('DiffBlock') || $('.add,.remove', this).size();
1456     });
1457
1458     var is_backward = direction == DIRECTION.BACKWARD;
1459     var index = focusable_nodes.index($(document.activeElement));
1460     
1461     var extra_filter = null;
1462
1463     if (index == -1) {
1464       if (is_backward)
1465         index = focusable_nodes.length;
1466       extra_filter = visibleNodeFilterFunction(is_backward);
1467     }
1468
1469     var offset = is_backward ? -1 : 1;
1470     var end = is_backward ? -1 : focusable_nodes.size();
1471     for (var i = index + offset; i != end; i = i + offset) {
1472       var node = $(focusable_nodes[i]);
1473       if (filter(node) && (!extra_filter || extra_filter(node))) {
1474         focusOn(node, is_backward);
1475         return true;
1476       }
1477     }
1478     return false;
1479   }
1480
1481   var DIRECTION = {FORWARD: 1, BACKWARD: 2};
1482
1483   function isComment(node) {
1484     return node.hasClass('frozenComment') || node.hasClass('previousComment') || node.hasClass('overallComments');
1485   }
1486   
1487   function isDiffBlock(node) {
1488     return node.hasClass('DiffBlock');
1489   }
1490   
1491   function isLine(node) {
1492     return node.hasClass('Line');
1493   }
1494
1495   function commentTextareaForKeyTarget(key_target) {
1496     if (key_target.nodeName == 'TEXTAREA')
1497       return $(key_target);
1498
1499     var comment_textarea = $(document.activeElement).prev().find('textarea');
1500     if (!comment_textarea.size())
1501       return null;
1502     return comment_textarea;
1503   }
1504
1505   function extendCommentContextUp(key_target) {
1506     var comment_textarea = commentTextareaForKeyTarget(key_target);
1507     if (!comment_textarea)
1508       return;
1509
1510     var comment_base_line = comment_textarea.attr('data-comment-for');
1511     var diff_section = diffSectionFor(comment_textarea);
1512     var lines = $('.Line', diff_section);
1513     for (var i = 0; i < lines.length - 1; i++) {
1514       if (hasDataCommentBaseLine(lines[i + 1], comment_base_line)) {
1515         addDataCommentBaseLine(lines[i], comment_base_line);
1516         break;
1517       }
1518     }
1519   }
1520
1521   function shrinkCommentContextDown(key_target) {
1522     var comment_textarea = commentTextareaForKeyTarget(key_target);
1523     if (!comment_textarea)
1524       return;
1525
1526     var comment_base_line = comment_textarea.attr('data-comment-for');
1527     var diff_section = diffSectionFor(comment_textarea);
1528     var lines = contextLinesFor(comment_base_line, diff_section);
1529     if (lines.size() > 1)
1530       removeDataCommentBaseLine(lines[0], comment_base_line);
1531   }
1532
1533   function handleModifyContextKey(e) {
1534     var handled = false;
1535
1536     if (e.shiftKey && e.ctrlKey) {
1537       switch (e.keyCode) {
1538       case KEY_CODE.up:
1539         extendCommentContextUp(e.target);
1540         handled = true;
1541         break;
1542
1543       case KEY_CODE.down:
1544         shrinkCommentContextDown(e.target);
1545         handled = true;
1546         break;
1547       }
1548     }
1549
1550     if (handled)
1551       e.preventDefault();
1552
1553     return handled;
1554   }
1555
1556   $('textarea').live('keydown', function(e) {
1557     if (handleModifyContextKey(e))
1558       return;
1559
1560     if (e.keyCode == KEY_CODE.escape)
1561       handleEscapeKeyInTextarea(this);
1562   });
1563
1564   $('body').live('keydown', function(e) {
1565     // FIXME: There's got to be a better way to avoid seeing these keypress
1566     // events.
1567     if (e.target.nodeName == 'TEXTAREA')
1568       return;
1569
1570     // Don't want to override browser shortcuts like ctrl+r.
1571     if (e.metaKey || e.ctrlKey)
1572       return;
1573
1574     if (handleModifyContextKey(e))
1575       return;
1576
1577     var handled = false;
1578     switch (e.keyCode) {
1579     case KEY_CODE.r:
1580       $('.review select').focus();
1581       handled = true;
1582       break;
1583
1584     case KEY_CODE.n:
1585       handled = focusNext(isComment, DIRECTION.FORWARD);
1586       break;
1587
1588     case KEY_CODE.p:
1589       handled = focusNext(isComment, DIRECTION.BACKWARD);
1590       break;
1591
1592     case KEY_CODE.j:
1593       if (e.shiftKey)
1594         handled = focusNext(isLine, DIRECTION.FORWARD);
1595       else
1596         handled = focusNext(isDiffBlock, DIRECTION.FORWARD);
1597       break;
1598
1599     case KEY_CODE.k:
1600       if (e.shiftKey)
1601         handled = focusNext(isLine, DIRECTION.BACKWARD);
1602       else
1603         handled = focusNext(isDiffBlock, DIRECTION.BACKWARD);
1604       break;
1605       
1606     case KEY_CODE.enter:
1607       handled = handleEnterKey();
1608       break;
1609       
1610     case KEY_CODE.escape:
1611       hideMoreHelp();
1612       handled = true;
1613       break;
1614     }
1615     
1616     if (handled)
1617       e.preventDefault();
1618   });
1619   
1620   function handleEscapeKeyInTextarea(textarea) {
1621     var comment = $(textarea).parents('.comment');
1622     if (comment.size())
1623       acceptComment(comment);
1624
1625     textarea.blur();
1626     document.body.focus();
1627   }
1628   
1629   function handleEnterKey() {
1630     if (document.activeElement.nodeName == 'BODY')
1631       return;
1632
1633     var focused = $(document.activeElement);
1634
1635     if (focused.hasClass('frozenComment')) {
1636       unfreezeComment(focused);
1637       return true;
1638     }
1639     
1640     if (focused.hasClass('overallComments')) {
1641       openOverallComments();
1642       focused.find('textarea')[0].focus();
1643       return true;
1644     }
1645     
1646     if (focused.hasClass('previousComment')) {
1647       addCommentField(focused);
1648       return true;
1649     }
1650
1651     var lines = focused.hasClass('Line') ? focused : $('.Line', focused);
1652     var last = lines.last();
1653     if (last.attr('data-has-comment')) {
1654       unfreezeCommentFor(last);
1655       return true;
1656     }
1657
1658     addCommentForLines(lines);
1659     return true;
1660   }
1661
1662   function contextLinesFor(comment_base_lines, file_diff) {
1663     var base_lines = comment_base_lines.split(' ');
1664     return $('div[data-comment-base-line]', file_diff).filter(function() {
1665       return $(this).attr('data-comment-base-line').split(' ').some(function(item) {
1666         return base_lines.indexOf(item) != -1;
1667       });
1668     });
1669   }
1670
1671   function numberFrom(line_id) {
1672     return Number(line_id.replace('line', ''));
1673   }
1674
1675   function trimCommentContextToBefore(line, comment_base_line) {
1676     var line_to_trim_to = numberFrom(line.attr('id'));
1677     contextLinesFor(comment_base_line, fileDiffFor(line)).each(function() {
1678       var id = $(this).attr('id');
1679       if (numberFrom(id) > line_to_trim_to)
1680         return;
1681
1682       removeDataCommentBaseLine(this, comment_base_line);
1683     });
1684   }
1685
1686   var drag_select_start_index = -1;
1687
1688   function lineOffsetFrom(line, offset) {
1689     var file_diff = line.parents('.FileDiff');
1690     var all_lines = $('.Line', file_diff);
1691     var index = all_lines.index(line);
1692     return $(all_lines[index + offset]);
1693   }
1694
1695   function previousLineFor(line) {
1696     return lineOffsetFrom(line, -1);
1697   }
1698
1699   function nextLineFor(line) {
1700     return lineOffsetFrom(line, 1);
1701   }
1702
1703   $(document.body).bind('mouseup', processSelectedLines);
1704
1705   $('.lineNumber').live('click', function(e) {
1706     var line = lineFromLineDescendant($(this));
1707     if (line.hasClass('commentContext'))
1708       trimCommentContextToBefore(previousLineFor(line), line.attr('data-comment-base-line'));
1709     else if (e.shiftKey)
1710       extendCommentContextTo(line);
1711   }).live('mousedown', function(e) {
1712     // preventDefault to avoid selecting text when dragging to select comment context lines.
1713     // FIXME: should we use user-modify CSS instead?
1714     e.preventDefault();
1715     if (e.shiftKey)
1716       return;
1717
1718     var line = lineFromLineDescendant($(this));
1719     drag_select_start_index = numberFrom(line.attr('id'));
1720     line.addClass('selected');
1721   });
1722
1723   $('.LineContainer').live('mouseenter', function(e) {
1724     if (drag_select_start_index == -1 || e.shiftKey)
1725       return;
1726     selectToLineContainer(this);
1727   }).live('mouseup', function(e) {
1728     if (drag_select_start_index == -1 || e.shiftKey)
1729       return;
1730
1731     selectToLineContainer(this);
1732     processSelectedLines();
1733   });
1734
1735   function extendCommentContextTo(line) {
1736     var diff_section = diffSectionFor(line);
1737     var lines = $('.Line', diff_section);
1738     var lines_to_modify = [];
1739     var have_seen_start_line = false;
1740     var data_comment_base_line = null;
1741     lines.each(function() {
1742       if (data_comment_base_line)
1743         return;
1744
1745       have_seen_start_line = have_seen_start_line || this == line[0];
1746       
1747       if (have_seen_start_line) {
1748         if ($(this).hasClass('commentContext'))
1749           data_comment_base_line = $(this).attr('data-comment-base-line');
1750         else
1751           lines_to_modify.push(this);
1752       }
1753     });
1754     
1755     // There is no comment context to extend.
1756     if (!data_comment_base_line)
1757       return;
1758     
1759     $(lines_to_modify).each(function() {
1760       $(this).addClass('commentContext');
1761       $(this).attr('data-comment-base-line', data_comment_base_line);
1762     });
1763   }
1764
1765   function selectTo(focus_index) {
1766     var selected = $('.selected').removeClass('selected');
1767     var is_backward = drag_select_start_index > focus_index;
1768     var current_index = is_backward ? focus_index : drag_select_start_index;
1769     var last_index = is_backward ? drag_select_start_index : focus_index;
1770     while (current_index <= last_index) {
1771       $('#line' + current_index).addClass('selected')
1772       current_index++;
1773     }
1774   }
1775
1776   function selectToLineContainer(line_container) {
1777     var line = lineFromLineContainer(line_container);
1778
1779     // Ensure that the selected lines are all contained in the same DiffSection.
1780     var selected_lines = $('.selected');
1781     var selected_diff_section = diffSectionFor(selected_lines.first());
1782     var new_diff_section = diffSectionFor(line);
1783     if (new_diff_section[0] != selected_diff_section[0]) {
1784       var lines = $('.Line', selected_diff_section);
1785       if (numberFrom(selected_lines.first().attr('id')) == drag_select_start_index)
1786         line = lines.last();
1787       else
1788         line = lines.first();
1789     }
1790     
1791     selectTo(numberFrom(line.attr('id')));
1792   }
1793
1794   function processSelectedLines() {
1795     drag_select_start_index = -1;
1796     addCommentForLines($('.selected'));
1797   }
1798   
1799   function addCommentForLines(lines) {    
1800     if (!lines.size())
1801       return;
1802
1803     var already_has_comment = lines.last().hasClass('commentContext');
1804
1805     var comment_base_line;
1806     if (already_has_comment)
1807       comment_base_line = lines.last().attr('data-comment-base-line');
1808     else {
1809       var last = lineFromLineDescendant(lines.last());
1810       addCommentFor($(last));
1811       comment_base_line = last.attr('id');
1812     }
1813
1814     lines.each(function() {
1815       addDataCommentBaseLine(this, comment_base_line);
1816       $(this).removeClass('selected');
1817     });
1818
1819     saveDraftComments();
1820   }
1821
1822   function hasDataCommentBaseLine(line, id) {
1823     var val = $(line).attr('data-comment-base-line');
1824     if (!val)
1825       return false;
1826
1827     var parts = val.split(' ');
1828     for (var i = 0; i < parts.length; i++) {
1829       if (parts[i] == id)
1830         return true;
1831     }
1832     return false;
1833   }
1834
1835   function addDataCommentBaseLine(line, id) {
1836     $(line).addClass('commentContext');
1837     if (hasDataCommentBaseLine(line, id))
1838       return;
1839
1840     var val = $(line).attr('data-comment-base-line');
1841     var parts = val ? val.split(' ') : [];
1842     parts.push(id);
1843     $(line).attr('data-comment-base-line', parts.join(' '));
1844   }
1845
1846   function removeDataCommentBaseLine(line, comment_base_lines) {
1847     var val = $(line).attr('data-comment-base-line');
1848     if (!val)
1849       return;
1850
1851     var base_lines = comment_base_lines.split(' ');
1852     var parts = val.split(' ');
1853     var new_parts = [];
1854     for (var i = 0; i < parts.length; i++) {
1855       if (base_lines.indexOf(parts[i]) == -1)
1856         new_parts.push(parts[i]);
1857     }
1858
1859     var new_comment_base_line = new_parts.join(' ');
1860     if (new_comment_base_line)
1861       $(line).attr('data-comment-base-line', new_comment_base_line);
1862     else {
1863       $(line).removeAttr('data-comment-base-line');
1864       $(line).removeClass('commentContext');
1865     }
1866   }
1867
1868   function lineFromLineDescendant(descendant) {
1869     return descendant.hasClass('Line') ? descendant : descendant.parents('.Line');
1870   }
1871
1872   function lineFromLineContainer(lineContainer) {
1873     var line = $(lineContainer);
1874     if (!line.hasClass('Line'))
1875       line = $('.Line', line);
1876     return line;
1877   }
1878
1879   function contextSnippetFor(line, indent) {
1880     var snippets = []
1881     contextLinesFor(line.attr('id'), fileDiffFor(line)).each(function() {
1882       var action = ' ';
1883       if ($(this).hasClass('add'))
1884         action = '+';
1885       else if ($(this).hasClass('remove'))
1886         action = '-';
1887       snippets.push(indent + action + textContentsFor(this));
1888     });
1889     return snippets.join('\n');
1890   }
1891
1892   function fileNameFor(line) {
1893     return fileDiffFor(line).find('h1').text();
1894   }
1895
1896   function indentFor(depth) {
1897     return (new Array(depth + 1)).join('>') + ' ';
1898   }
1899
1900   function snippetFor(line, indent) {
1901     var file_name = fileNameFor(line);
1902     var line_number = line.hasClass('remove') ? '-' + fromLineNumber(line[0]) : toLineNumber(line[0]);
1903     return indent + file_name + ':' + line_number + '\n' + contextSnippetFor(line, indent);
1904   }
1905
1906   function quotePreviousComments(comments) {
1907     var quoted_comments = [];
1908     var depth = comments.size();
1909     comments.each(function() {
1910       var indent = indentFor(depth--);
1911       var text = $(this).children('.content').text();
1912       quoted_comments.push(indent + '\n' + indent + text.split('\n').join('\n' + indent));
1913     });
1914     return quoted_comments.join('\n');
1915   }
1916
1917   $('#comment_form .winter').live('click', hideCommentForm);
1918
1919   function fillInReviewForm() {
1920     var comments_in_context = []
1921     forEachLine(function(line) {
1922       if (line.attr('data-has-comment') != 'true')
1923         return;
1924       var comment = findCommentBlockFor(line).children('textarea').val().trim();
1925       if (comment == '')
1926         return;
1927       var previous_comments = previousCommentsFor(line);
1928       var snippet = snippetFor(line, indentFor(previous_comments.size() + 1));
1929       var quoted_comments = quotePreviousComments(previous_comments);
1930       var comment_with_context = [];
1931       comment_with_context.push(snippet);
1932       if (quoted_comments != '')
1933         comment_with_context.push(quoted_comments);
1934       comment_with_context.push('\n' + comment);
1935       comments_in_context.push(comment_with_context.join('\n'));
1936     });
1937     var comment = $('.overallComments textarea').val().trim();
1938     if (comment != '')
1939       comment += '\n\n';
1940     comment += comments_in_context.join('\n\n');
1941     if (comments_in_context.length > 0)
1942       comment = 'View in context: ' + window.location + '\n\n' + comment;
1943     var review_form = $('#reviewform').contents();
1944     review_form.find('#comment').val(comment);
1945     review_form.find('#flags select').each(function() {
1946       var control = findControlForFlag(this);
1947       if (!control.size())
1948         return;
1949       $(this).attr('selectedIndex', control.attr('selectedIndex'));
1950     });
1951   }
1952
1953   function showCommentForm() {
1954     $('#comment_form').removeClass('inactive');
1955     $('#reviewform').contents().find('#submitBtn').focus();
1956   }
1957   
1958   function hideCommentForm() {
1959     $('#comment_form').addClass('inactive');
1960     
1961     // Make sure the top document has focus so key events don't keep going to the review form.
1962     document.body.tabIndex = -1;
1963     document.body.focus();
1964   }
1965
1966   $('#preview_comments').live('click', function() {
1967     fillInReviewForm();
1968     showCommentForm();
1969   });
1970
1971   $('#post_comments').live('click', function() {
1972     fillInReviewForm();
1973     $('#reviewform').contents().find('form').submit();
1974   });
1975 })();