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