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