Use sticky positioning for the code review toolbar
[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         var new_link = $('<a target="_blank">header</a>');
642         var stem = file_name.substr(0, file_name.length - suffix.length);
643         new_link[0].href= 'http://trac.webkit.org/log/trunk/' + stem + '.h';
644         trac_links = $.merge(new_link, trac_links);
645       }
646     }
647     return trac_links;
648   }
649
650   function isChangeLog(file_name) {
651     return file_name.match(/\/ChangeLog$/) || file_name == 'ChangeLog';
652   }
653
654   function addExpandLinks(file_name) {
655     if (isChangeLog(file_name))
656       return;
657
658     var file_diff = files[file_name];
659
660     // Don't show the links to expand upwards/downwards if the patch starts/ends without context
661     // lines, i.e. starts/ends with add/remove lines.
662     var first_line = file_diff.querySelector('.LineContainer:not(.context)');
663
664     // If there is no element with a "Line" class, then this is an image diff.
665     if (!first_line)
666       return;
667
668     var expand_bar_index = 0;
669     if (!$(first_line).hasClass('add') && !$(first_line).hasClass('remove'))
670       $('h1', file_diff).after(expandBarHtml(BELOW))
671
672     $('br', file_diff).replaceWith(expandBarHtml());
673
674     // jquery doesn't support :last-of-type, so use querySelector instead.
675     var last_line = file_diff.querySelector('.LineContainer:last-of-type');
676     // Some patches for new files somehow end up with an empty context line at the end
677     // with a from line number of 0. Don't show expand links in that case either.
678     if (!$(last_line).hasClass('add') && !$(last_line).hasClass('remove') && fromLineNumber(last_line) != 0)
679       $(file_diff.querySelector('.DiffSection:last-of-type')).after(expandBarHtml(ABOVE));
680   }
681
682   function expandBarHtml(opt_direction) {
683     var html = '<div class="ExpandBar">' +
684         '<div class="ExpandArea Expand' + ABOVE + '"></div>' +
685         '<div class="ExpandLinkContainer LinkContainer"><span class="ExpandText">expand: </span>';
686
687     // FIXME: If there are <100 line to expand, don't show the expand-100 link.
688     // If there are <20 lines to expand, don't show the expand-20 link.
689     if (!opt_direction || opt_direction == ABOVE) {
690       html += expandLinkHtml(ABOVE, 100) +
691           expandLinkHtml(ABOVE, 20);
692     }
693
694     html += expandLinkHtml(ALL);
695
696     if (!opt_direction || opt_direction == BELOW) {
697       html += expandLinkHtml(BELOW, 20) +
698         expandLinkHtml(BELOW, 100);
699     }
700
701     html += '</div><div class="ExpandArea Expand' + BELOW + '"></div></div>';
702     return html;
703   }
704
705   function expandLinkHtml(direction, amount) {
706     return "<a class='ExpandLink' href='javascript:' data-direction='" + direction + "' data-amount='" + amount + "'>" +
707         (amount ? amount + " " : "") + direction + "</a>";
708   }
709
710   function handleExpandLinkClick() {
711     var expand_bar = $(this).parents('.ExpandBar');
712     var file_name = expand_bar.parents('.FileDiff').children('h1')[0].textContent;
713     var expand_function = partial(expand, expand_bar[0], file_name, this.getAttribute('data-direction'), Number(this.getAttribute('data-amount')));
714     if (file_name in original_file_contents)
715       expand_function();
716     else
717       getWebKitSourceFile(file_name, expand_function, expand_bar);
718   }
719
720   function handleSideBySideLinkClick() {
721     convertDiff('sidebyside', this);
722   }
723
724   function handleUnifyLinkClick() {
725     convertDiff('unified', this);
726   }
727
728   function convertDiff(difftype, convert_link) {
729     var file_diffs = $(convert_link).parents('.FileDiff');
730     if (!file_diffs.size()) {
731       localStorage.setItem('code-review-diffstate', difftype);
732       file_diffs = $('.FileDiff');
733     }
734
735     convertAllFileDiffs(difftype, file_diffs);
736   }
737
738   function patchRevision() {
739     var revision = $('.revision');
740     return revision[0] ? revision.first().text() : null;
741   }
742
743   function getWebKitSourceFile(file_name, onLoad, expand_bar) {
744     function handleLoad(contents) {
745       original_file_contents[file_name] = contents.split('\n');
746       patched_file_contents[file_name] = applyDiff(original_file_contents[file_name], file_name);
747       onLoad();
748     };
749
750     var revision = patchRevision();
751     var queryParameters = revision ? '?p=' + revision : '';
752
753     $.ajax({
754       url: WEBKIT_BASE_DIR + file_name + queryParameters,
755       context: document.body,
756       complete: function(xhr, data) {
757               if (xhr.status == 0)
758                   handleLoadError(expand_bar);
759               else
760                   handleLoad(xhr.responseText);
761       }
762     });
763   }
764
765   function replaceExpandLinkContainers(expand_bar, text) {
766     $('.ExpandLinkContainer', $(expand_bar).parents('.FileDiff')).replaceWith('<span class="ExpandText">' + text + '</span>');
767   }
768
769   function handleLoadError(expand_bar) {
770     replaceExpandLinkContainers(expand_bar, "Can't expand. Is this a new or deleted file?");
771   }
772
773   var ABOVE = 'above';
774   var BELOW = 'below';
775   var ALL = 'all';
776
777   function lineNumbersFromSet(set, is_last) {
778     var to = -1;
779     var from = -1;
780
781     var size = set.size();
782     var start = is_last ? (size - 1) : 0;
783     var end = is_last ? -1 : size;
784     var offset = is_last ? -1 : 1;
785
786     for (var i = start; i != end; i += offset) {
787       if (to != -1 && from != -1)
788         return {to: to, from: from};
789
790       var line_number = set[i];
791       if ($(line_number).hasClass('to')) {
792         if (to == -1)
793           to = Number(line_number.textContent);
794       } else {
795         if (from == -1)
796           from = Number(line_number.textContent);
797       }
798     }
799   }
800
801   function removeContextBarBelow(expand_bar) {
802     $('.context', expand_bar.nextElementSibling).detach();
803   }
804
805   function expand(expand_bar, file_name, direction, amount) {
806     if (file_name in original_file_contents && !patched_file_contents[file_name]) {
807       // FIXME: In this case, try fetching the source file at the revision the patch was created at.
808       // Might need to modify webkit-patch to include that data in the diff.
809       replaceExpandLinkContainers(expand_bar, "Can't expand. Unable to apply patch to tip of tree.");
810       return;
811     }
812
813     var above_expansion = expand_bar.querySelector('.Expand' + ABOVE)
814     var below_expansion = expand_bar.querySelector('.Expand' + BELOW)
815
816     var above_line_numbers = $('.expansionLineNumber', above_expansion);
817     if (!above_line_numbers[0]) {
818       var diff_section = expand_bar.previousElementSibling;
819       above_line_numbers = $('.Line:not(.context) .lineNumber', diff_section);
820     }
821
822     var above_last_line_num, above_last_from_line_num;
823     if (above_line_numbers[0]) {
824       var above_numbers = lineNumbersFromSet(above_line_numbers, true);
825       above_last_line_num = above_numbers.to;
826       above_last_from_line_num = above_numbers.from;
827     } else
828       above_last_from_line_num = above_last_line_num = 0;
829
830     var below_line_numbers = $('.expansionLineNumber', below_expansion);
831     if (!below_line_numbers[0]) {
832       var diff_section = expand_bar.nextElementSibling;
833       if (diff_section)
834         below_line_numbers = $('.Line:not(.context) .lineNumber', diff_section);
835     }
836
837     var below_first_line_num, below_first_from_line_num;
838     if (below_line_numbers[0]) {
839       var below_numbers = lineNumbersFromSet(below_line_numbers, false);
840       below_first_line_num = below_numbers.to - 1;
841       below_first_from_line_num = below_numbers.from - 1;
842     } else
843       below_first_from_line_num = below_first_line_num = patched_file_contents[file_name].length - 1;
844
845     var start_line_num, start_from_line_num;
846     var end_line_num;
847
848     if (direction == ABOVE) {
849       start_from_line_num = above_last_from_line_num;
850       start_line_num = above_last_line_num;
851       end_line_num = Math.min(start_line_num + amount, below_first_line_num);
852     } else if (direction == BELOW) {
853       end_line_num = below_first_line_num;
854       start_line_num = Math.max(end_line_num - amount, above_last_line_num)
855       start_from_line_num = Math.max(below_first_from_line_num - amount, above_last_from_line_num)
856     } else { // direction == ALL
857       start_line_num = above_last_line_num;
858       start_from_line_num = above_last_from_line_num;
859       end_line_num = below_first_line_num;
860     }
861
862     var lines = expansionLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num);
863
864     var expansion_area;
865     // Filling in all the remaining lines. Overwrite the expand links.
866     if (start_line_num == above_last_line_num && end_line_num == below_first_line_num) {
867       $('.ExpandLinkContainer', expand_bar).detach();
868       below_expansion.insertBefore(lines, below_expansion.firstChild);
869       removeContextBarBelow(expand_bar);
870     } else if (direction == ABOVE) {
871       above_expansion.appendChild(lines);
872     } else {
873       below_expansion.insertBefore(lines, below_expansion.firstChild);
874       removeContextBarBelow(expand_bar);
875     }
876   }
877
878   function unifiedLine(from, to, contents, is_expansion_line, opt_className, opt_attributes) {
879     var className = is_expansion_line ? 'ExpansionLine' : 'LineContainer Line';
880     if (opt_className)
881       className += ' ' + opt_className;
882
883     var lineNumberClassName = is_expansion_line ? 'expansionLineNumber' : 'lineNumber';
884
885     var line = $('<div class="' + className + '" ' + (opt_attributes || '') + '>' +
886         '<span class="from ' + lineNumberClassName + '">' + (from || '&nbsp;') +
887         '</span><span class="to ' + lineNumberClassName + '">' + (to || '&nbsp;') +
888         '</span><span class="text"></span>' +
889         '</div>');
890
891     $('.text', line).replaceWith(contents);
892     return line;
893   }
894
895   function unifiedExpansionLine(from, to, contents) {
896     return unifiedLine(from, to, contents, true);
897   }
898
899   function sideBySideExpansionLine(from, to, contents) {
900     var line = $('<div class="ExpansionLine"></div>');
901     // Clone the contents so we have two copies we can put back in the DOM.
902     line.append(lineSide('from', contents.clone(true), true, from));
903     line.append(lineSide('to', contents, true, to));
904     return line;
905   }
906
907   function lineSide(side, contents, is_expansion_line, opt_line_number, opt_attributes, opt_class) {
908     var class_name = '';
909     if (opt_attributes || opt_class) {
910       class_name = 'class="';
911       if (opt_attributes)
912         class_name += is_expansion_line ? 'ExpansionLine' : 'Line';
913       class_name += ' ' + (opt_class || '') + '"';
914     }
915
916     var attributes = opt_attributes || '';
917
918     var line_side = $('<div class="LineSide">' +
919         '<div ' + attributes + ' ' + class_name + '>' +
920           '<span class="' + side + ' ' + (is_expansion_line ? 'expansionLineNumber' : 'lineNumber') + '">' +
921               (opt_line_number || '&nbsp;') +
922           '</span>' +
923           '<span class="text"></span>' +
924         '</div>' +
925         '</div>');
926
927     $('.text', line_side).replaceWith(contents);
928     return line_side;
929   }
930
931   function expansionLines(file_name, expansion_area, direction, start_line_num, end_line_num, start_from_line_num) {
932     var fragment = document.createDocumentFragment();
933     var is_side_by_side = isDiffSideBySide(files[file_name]);
934
935     for (var i = 0; i < end_line_num - start_line_num; i++) {
936       var from = start_from_line_num + i + 1;
937       var to = start_line_num + i + 1;
938       var contents = $('<span class="text"></span>');
939       contents.text(patched_file_contents[file_name][start_line_num + i]);
940       var line = is_side_by_side ? sideBySideExpansionLine(from, to, contents) : unifiedExpansionLine(from, to, contents);
941       fragment.appendChild(line[0]);
942     }
943
944     return fragment;
945   }
946
947   function hunkStartingLine(patched_file, context, prev_line, hunk_num) {
948     var current_line = -1;
949     var last_context_line = context[context.length - 1];
950     if (patched_file[prev_line] == last_context_line)
951       current_line = prev_line + 1;
952     else {
953       console.log('Hunk #' + hunk_num + ' FAILED.');
954       return -1;
955     }
956
957     // For paranoia sake, confirm the rest of the context matches;
958     for (var i = 0; i < context.length - 1; i++) {
959       if (patched_file[current_line - context.length + i] != context[i]) {
960         console.log('Hunk #' + hunk_num + ' FAILED. Did not match preceding context.');
961         return -1;
962       }
963     }
964
965     return current_line;
966   }
967
968   function fromLineNumber(line) {
969     var node = line.querySelector('.from');
970     return node ? Number(node.textContent) : 0;
971   }
972
973   function toLineNumber(line) {
974     var node = line.querySelector('.to');
975     return node ? Number(node.textContent) : 0;
976   }
977
978   function textContentsFor(line) {
979     // Just get the first match since a side-by-side diff has two lines with text inside them for
980     // unmodified lines in the diff.
981     return $('.text', line).first().text();
982   }
983
984   function lineNumberForFirstNonContextLine(patched_file, line, prev_line, context, hunk_num) {
985     if (context.length) {
986       var prev_line_num = fromLineNumber(prev_line) - 1;
987       return hunkStartingLine(patched_file, context, prev_line_num, hunk_num);
988     }
989
990     if (toLineNumber(line) == 1 || fromLineNumber(line) == 1)
991       return 0;
992
993     console.log('Failed to apply patch. Adds or removes lines before any context lines.');
994     return -1;
995   }
996
997   function applyDiff(original_file, file_name) {
998     var diff_sections = files[file_name].getElementsByClassName('DiffSection');
999     var patched_file = original_file.concat([]);
1000
1001     // Apply diffs in reverse order to avoid needing to keep track of changing line numbers.
1002     for (var i = diff_sections.length - 1; i >= 0; i--) {
1003       var section = diff_sections[i];
1004       var lines = $('.Line:not(.context)', section);
1005       var current_line = -1;
1006       var context = [];
1007       var hunk_num = i + 1;
1008
1009       for (var j = 0, lines_len = lines.length; j < lines_len; j++) {
1010         var line = lines[j];
1011         var line_contents = textContentsFor(line);
1012         if ($(line).hasClass('add')) {
1013           if (current_line == -1) {
1014             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
1015             if (current_line == -1)
1016               return null;
1017           }
1018
1019           patched_file.splice(current_line, 0, line_contents);
1020           current_line++;
1021         } else if ($(line).hasClass('remove')) {
1022           if (current_line == -1) {
1023             current_line = lineNumberForFirstNonContextLine(patched_file, line, lines[j-1], context, hunk_num);
1024             if (current_line == -1)
1025               return null;
1026           }
1027
1028           if (patched_file[current_line] != line_contents) {
1029             console.log('Hunk #' + hunk_num + ' FAILED.');
1030             return null;
1031           }
1032
1033           patched_file.splice(current_line, 1);
1034         } else if (current_line == -1) {
1035           context.push(line_contents);
1036         } else if (line_contents != patched_file[current_line]) {
1037           console.log('Hunk #' + hunk_num + ' FAILED. Context at end did not match');
1038           return null;
1039         } else {
1040           current_line++;
1041         }
1042       }
1043     }
1044
1045     return patched_file;
1046   }
1047
1048   function openOverallComments(e) {
1049     $('.overallComments textarea').addClass('open');
1050     $('#statusBubbleContainer').addClass('wrap');
1051   }
1052
1053   var g_overallCommentsInputTimer;
1054
1055   function handleOverallCommentsInput() {
1056     setAutoSaveStateIndicator('saving');
1057     // Save draft comments after we haven't received an input event in 1 second.
1058     if (g_overallCommentsInputTimer)
1059       clearTimeout(g_overallCommentsInputTimer);
1060     g_overallCommentsInputTimer = setTimeout(saveDraftComments, 1000);
1061   }
1062
1063   function diffLinksHtml() {
1064     return '<a href="javascript:" class="unify-link">unified</a>' +
1065       '<a href="javascript:" class="side-by-side-link">side-by-side</a>';
1066   }
1067
1068   function appendToolbar() {
1069     $(document.body).append('<div id="toolbar">' +
1070         '<div class="overallComments">' +
1071           '<textarea placeholder="Overall comments"></textarea>' +
1072         '</div>' +
1073         '<div>' +
1074           '<span id="statusBubbleContainer"></span>' +
1075           '<span class="actions">' +
1076             '<span class="links"><span class="bugLink"></span></span>' +
1077             '<span id="flagContainer"></span>' +
1078             '<button id="preview_comments">Preview</button>' +
1079             '<button id="post_comments">Publish</button> ' +
1080           '</span>' +
1081           '<div class="clear_float"></div>' +
1082         '</div>' +
1083         '<div class="autosave-state"></div>' +
1084         '</div>');
1085
1086     $('.overallComments textarea').bind('click', openOverallComments);
1087     $('.overallComments textarea').bind('input', handleOverallCommentsInput);
1088
1089     var toolbar = $('#toolbar');
1090     toolbar.css('position', '-webkit-sticky');
1091     var supportsSticky = toolbar.css('position') == '-webkit-sticky';
1092     document.body.style.marginBottom = supportsSticky ? 0 : '40px';
1093   }
1094
1095   function handleDocumentReady() {
1096     crawlDiff();
1097     fetchHistory();
1098     $(document.body).prepend('<div id="message">' +
1099         '<div class="help">Select line numbers to add a comment. Scroll though diffs with the "j" and "k" keys.' +
1100           '<div class="DiffLinks LinkContainer">' + diffLinksHtml() + '</div>' +
1101           '<a href="javascript:" class="more">[more]</a>' +
1102           '<div class="more-help inactive">' +
1103             '<div class="winter"></div>' +
1104             '<div class="lightbox"><table>' +
1105               '<tr><td>enter</td><td>add/edit comment for focused item</td></tr>' +
1106               '<tr><td>escape</td><td>accept current comment / close preview and help popups</td></tr>' +
1107               '<tr><td>j</td><td>focus next diff</td></tr>' +
1108               '<tr><td>k</td><td>focus previous diff</td></tr>' +
1109               '<tr><td>shift + j</td><td>focus next line</td></tr>' +
1110               '<tr><td>shift + k</td><td>focus previous line</td></tr>' +
1111               '<tr><td>n</td><td>focus next comment</td></tr>' +
1112               '<tr><td>p</td><td>focus previous comment</td></tr>' +
1113               '<tr><td>r</td><td>focus review select element</td></tr>' +
1114               '<tr><td>ctrl + shift + up</td><td>extend context of the focused comment</td></tr>' +
1115               '<tr><td>ctrl + shift + down</td><td>shrink context of the focused comment</td></tr>' +
1116             '</table></div>' +
1117           '</div>' +
1118         '</div>' +
1119         '</div>');
1120
1121     appendToolbar();
1122
1123     $(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>');
1124     $('#reviewform').bind('load', handleReviewFormLoad);
1125
1126     loadDiffState();
1127     generateFileDiffResizeStyleElement();
1128   };
1129
1130   function handleReviewFormLoad() {
1131     var review_form_contents = $('#reviewform').contents();
1132     if (review_form_contents[0].querySelector('#form-controls #flags')) {
1133       review_form_contents.bind('keydown', function(e) {
1134         if (e.keyCode == KEY_CODE.escape)
1135           hideCommentForm();
1136       });
1137
1138       // This is the intial load of the review form iframe.
1139       var form = review_form_contents.find('form')[0];
1140       form.addEventListener('submit', eraseDraftComments);
1141       form.target = '';
1142       return;
1143     }
1144
1145     // Review form iframe have the publish button has been pressed.
1146     var email_sent_to = review_form_contents[0].querySelector('#bugzilla-body dl');
1147     // If the email_send_to DL is not in the tree that means the publish failed for some reason,
1148     // e.g., you're not logged in. Show the comment form to allow you to login.
1149     if (!email_sent_to) {
1150       showCommentForm();
1151       return;
1152     }
1153
1154     eraseDraftComments();
1155     // FIXME: Once WebKit supports seamless iframes, we can just make the review-form
1156     // iframe fill the page instead of redirecting back to the bug.
1157     window.location.replace($('#toolbar .bugLink a').attr('href'));
1158   }
1159   
1160   function eraseDraftComments() {
1161     g_draftCommentSaver.erase();
1162   }
1163
1164   function loadDiffState() {
1165     var diffstate = localStorage.getItem('code-review-diffstate');
1166     if (diffstate != 'sidebyside' && diffstate != 'unified')
1167       return;
1168
1169     convertAllFileDiffs(diffstate, $('.FileDiff'));
1170   }
1171
1172   function isDiffSideBySide(file_diff) {
1173     return diffState(file_diff) == 'sidebyside';
1174   }
1175
1176   function diffState(file_diff) {
1177     var diff_state = $(file_diff).attr('data-diffstate');
1178     return diff_state || 'unified';
1179   }
1180
1181   function unifyLine(line, from, to, contents, classNames, attributes, id) {
1182     var new_line = unifiedLine(from, to, contents, false, classNames, attributes);
1183     var old_line = $(line);
1184     if (!old_line.hasClass('LineContainer'))
1185       old_line = old_line.parents('.LineContainer');
1186
1187     var comments = commentsToTransferFor($(document.getElementById(id)));
1188     old_line.after(comments);
1189     old_line.replaceWith(new_line);
1190   }
1191
1192   function updateDiffLinkVisibility(file_diff) {
1193     if (diffState(file_diff) == 'unified') {
1194       $('.side-by-side-link', file_diff).show();
1195       $('.unify-link', file_diff).hide();
1196     } else {
1197       $('.side-by-side-link', file_diff).hide();
1198       $('.unify-link', file_diff).show();
1199     }
1200   }
1201
1202   function convertAllFileDiffs(diff_type, file_diffs) {
1203     file_diffs.each(function() {
1204       convertFileDiff(diff_type, this);
1205     });
1206   }
1207
1208   function convertFileDiff(diff_type, file_diff) {
1209     if (diffState(file_diff) == diff_type)
1210       return;
1211
1212     if (!$('.resizeHandle', file_diff).length)
1213       $(file_diff).append('<div class="resizeHandle"></div>');
1214
1215     $(file_diff).removeClass('sidebyside unified');
1216     $(file_diff).addClass(diff_type);
1217
1218     $(file_diff).attr('data-diffstate', diff_type);
1219     updateDiffLinkVisibility(file_diff);
1220
1221     $('.context', file_diff).each(function() {
1222       convertLine(diff_type, this);
1223     });
1224
1225     $('.shared .Line', file_diff).each(function() {
1226       convertLine(diff_type, this);
1227     });
1228
1229     $('.ExpansionLine', file_diff).each(function() {
1230       convertExpansionLine(diff_type, this);
1231     });
1232   }
1233
1234   function convertLine(diff_type, line) {
1235     var convert_function = diff_type == 'sidebyside' ? sideBySideifyLine : unifyLine;
1236     var from = fromLineNumber(line);
1237     var to = toLineNumber(line);
1238     var contents = $('.text', line).first();
1239     var classNames = classNamesForMovingLine(line);
1240     var attributes = attributesForMovingLine(line);
1241     var id = line.id;
1242     convert_function(line, from, to, contents, classNames, attributes, id)
1243   }
1244
1245   function classNamesForMovingLine(line) {
1246     var classParts = line.className.split(' ');
1247     var classBuffer = [];
1248     for (var i = 0; i < classParts.length; i++) {
1249       var part = classParts[i];
1250       if (part != 'LineContainer' && part != 'Line')
1251         classBuffer.push(part);
1252     }
1253     return classBuffer.join(' ');
1254   }
1255
1256   function attributesForMovingLine(line) {
1257     var attributesBuffer = ['id=' + line.id];
1258     // Make sure to keep all data- attributes.
1259     $(line.attributes).each(function() {
1260       if (this.name.indexOf('data-') == 0)
1261         attributesBuffer.push(this.name + '=' + this.value);
1262     });
1263     return attributesBuffer.join(' ');
1264   }
1265
1266   function sideBySideifyLine(line, from, to, contents, classNames, attributes, id) {
1267     var from_class = '';
1268     var to_class = '';
1269     var from_attributes = '';
1270     var to_attributes = '';
1271     // Clone the contents so we have two copies we can put back in the DOM.
1272     var from_contents = contents.clone(true);
1273     var to_contents = contents;
1274
1275     var container_class = 'LineContainer';
1276     var container_attributes = '';
1277
1278     if (from && !to) { // This is a remove line.
1279       from_class = classNames;
1280       from_attributes = attributes;
1281       to_contents = '';
1282     } else if (to && !from) { // This is an add line.
1283       to_class = classNames;
1284       to_attributes = attributes;
1285       from_contents = '';
1286     } else {
1287       container_attributes = attributes;
1288       container_class += ' Line ' + classNames;
1289     }
1290
1291     var new_line = $('<div ' + container_attributes + ' class="' + container_class + '"></div>');
1292     new_line.append(lineSide('from', from_contents, false, from, from_attributes, from_class));
1293     new_line.append(lineSide('to', to_contents, false, to, to_attributes, to_class));
1294
1295     $(line).replaceWith(new_line);
1296
1297     var line = $(document.getElementById(id));
1298     line.after(commentsToTransferFor(line));
1299   }
1300
1301   function convertExpansionLine(diff_type, line) {
1302     var convert_function = diff_type == 'sidebyside' ? sideBySideExpansionLine : unifiedExpansionLine;
1303     var contents = $('.text', line).first();
1304     var from = fromLineNumber(line);
1305     var to = toLineNumber(line);
1306     var new_line = convert_function(from, to, contents);
1307     $(line).replaceWith(new_line);
1308   }
1309
1310   function commentsToTransferFor(line) {
1311     var fragment = document.createDocumentFragment();
1312
1313     previousCommentsFor(line).each(function() {
1314       fragment.appendChild(this);
1315     });
1316
1317     var active_comments = activeCommentFor(line);
1318     var num_active_comments = active_comments.size();
1319     if (num_active_comments > 0) {
1320       if (num_active_comments > 1)
1321         console.log('ERROR: There is more than one active comment for ' + line.attr('id') + '.');
1322
1323       var parent = active_comments[0].parentNode;
1324       var frozenComment = parent.nextSibling;
1325       fragment.appendChild(parent);
1326       fragment.appendChild(frozenComment);
1327     }
1328
1329     return fragment;
1330   }
1331
1332   function discardComment(comment_block) {
1333     var line_id = $(comment_block).find('textarea').attr('data-comment-for');
1334     var line = $('#' + line_id)
1335     $(comment_block).slideUp('fast', function() {
1336       $(this).remove();
1337       line.removeAttr('data-has-comment');
1338       trimCommentContextToBefore(line, line_id);
1339       saveDraftComments();
1340     });
1341   }
1342
1343   function handleUnfreezeComment() {
1344     unfreezeComment(this);
1345   }
1346
1347   function unfreezeComment(comment) {
1348     var unfrozen_comment = $(comment).prev();
1349     unfrozen_comment.show();
1350     $(comment).remove();
1351     unfrozen_comment.find('textarea')[0].focus();
1352   }
1353
1354   function showFileDiffLinks() {
1355     $('.LinkContainer', this).each(function() { this.style.opacity = 1; });
1356   }
1357
1358   function hideFileDiffLinks() {
1359     $('.LinkContainer', this).each(function() { this.style.opacity = 0; });
1360   }
1361
1362   function handleDiscardComment() {
1363     discardComment($(this).parents('.comment'));
1364   }
1365   
1366   function handleAcceptComment() {
1367     acceptComment($(this).parents('.comment'));
1368   }
1369   
1370   function acceptComment(comment) {
1371     var frozen_comment = freezeComment($(comment));
1372     focusOn(frozen_comment);
1373     saveDraftComments();
1374     return frozen_comment;
1375   }
1376
1377   $('.FileDiff').live('mouseenter', showFileDiffLinks);
1378   $('.FileDiff').live('mouseleave', hideFileDiffLinks);
1379   $('.side-by-side-link').live('click', handleSideBySideLinkClick);
1380   $('.unify-link').live('click', handleUnifyLinkClick);
1381   $('.ExpandLink').live('click', handleExpandLinkClick);
1382   $('.frozenComment').live('click', handleUnfreezeComment);
1383   $('.comment .discard').live('click', handleDiscardComment);
1384   $('.comment .ok').live('click', handleAcceptComment);
1385   $('.more').live('click', showMoreHelp);
1386   $('.more-help .winter').live('click', hideMoreHelp);
1387
1388   function freezeComment(comment_block) {
1389     var comment_textarea = comment_block.find('textarea');
1390     if (comment_textarea.val().trim() == '') {
1391       discardComment(comment_block);
1392       return;
1393     }
1394     var line_id = comment_textarea.attr('data-comment-for');
1395     var line = $('#' + line_id)
1396     var frozen_comment = $('<div class="frozenComment"></div>').text(comment_textarea.val());
1397     findCommentBlockFor(line).hide().after(frozen_comment);
1398     return frozen_comment;
1399   }
1400
1401   function focusOn(node, opt_is_backward) {
1402     if (node.length == 0)
1403       return;
1404
1405     // Give a tabindex so the element can receive actual browser focus.
1406     // -1 makes the element focusable without actually putting in in the tab order.
1407     node.attr('tabindex', -1);
1408     node.focus();
1409     // Remove the tabindex on blur to avoid having the node be mouse-focusable.
1410     node.bind('blur', function() { node.removeAttr('tabindex'); });
1411     
1412     var node_top = node.offset().top;
1413     var is_top_offscreen = node_top <= $(document).scrollTop();
1414     
1415     var half_way_point = $(document).scrollTop() + window.innerHeight / 2;
1416     var is_top_past_halfway = opt_is_backward ? node_top < half_way_point : node_top > half_way_point;
1417
1418     if (is_top_offscreen || is_top_past_halfway)
1419       $(document).scrollTop(node_top - window.innerHeight / 2);
1420   }
1421
1422   function visibleNodeFilterFunction(is_backward) {
1423     var y = is_backward ? $('#toolbar')[0].offsetTop - 1 : 0;
1424     var x = window.innerWidth / 2;
1425     var reference_element = document.elementFromPoint(x, y);
1426
1427     if (reference_element.nodeName == 'HTML' || reference_element.nodeName == 'BODY') {
1428       // In case we hit test a margin between file diffs, shift a fudge factor and try again.
1429       // FIXME: Is there a better way to do this?
1430       var file_diffs = $('.FileDiff');
1431       var first_diff = file_diffs.first();
1432       var second_diff = $(file_diffs[1]);
1433       var distance_between_file_diffs = second_diff.position().top - first_diff.position().top - first_diff.height();
1434
1435       if (is_backward)
1436         y -= distance_between_file_diffs;
1437       else
1438         y += distance_between_file_diffs;
1439
1440       reference_element = document.elementFromPoint(x, y);
1441     }
1442
1443     if (reference_element.nodeName == 'HTML' || reference_element.nodeName == 'BODY')
1444       return null;
1445     
1446     return function(node) {
1447       var compare = reference_element.compareDocumentPosition(node[0]);
1448       if (is_backward)
1449         return compare & Node.DOCUMENT_POSITION_PRECEDING;
1450       return compare & Node.DOCUMENT_POSITION_FOLLOWING;
1451     }
1452   }
1453
1454   function focusNext(filter, direction) {
1455     var focusable_nodes = $('a,.Line,.frozenComment,.previousComment,.DiffBlock,.overallComments').filter(function() {
1456       return !$(this).hasClass('DiffBlock') || $('.add,.remove', this).size();
1457     });
1458
1459     var is_backward = direction == DIRECTION.BACKWARD;
1460     var index = focusable_nodes.index($(document.activeElement));
1461     
1462     var extra_filter = null;
1463
1464     if (index == -1) {
1465       if (is_backward)
1466         index = focusable_nodes.length;
1467       extra_filter = visibleNodeFilterFunction(is_backward);
1468     }
1469
1470     var offset = is_backward ? -1 : 1;
1471     var end = is_backward ? -1 : focusable_nodes.size();
1472     for (var i = index + offset; i != end; i = i + offset) {
1473       var node = $(focusable_nodes[i]);
1474       if (filter(node) && (!extra_filter || extra_filter(node))) {
1475         focusOn(node, is_backward);
1476         return true;
1477       }
1478     }
1479     return false;
1480   }
1481
1482   var DIRECTION = {FORWARD: 1, BACKWARD: 2};
1483
1484   function isComment(node) {
1485     return node.hasClass('frozenComment') || node.hasClass('previousComment') || node.hasClass('overallComments');
1486   }
1487   
1488   function isDiffBlock(node) {
1489     return node.hasClass('DiffBlock');
1490   }
1491   
1492   function isLine(node) {
1493     return node.hasClass('Line');
1494   }
1495
1496   function commentTextareaForKeyTarget(key_target) {
1497     if (key_target.nodeName == 'TEXTAREA')
1498       return $(key_target);
1499
1500     var comment_textarea = $(document.activeElement).prev().find('textarea');
1501     if (!comment_textarea.size())
1502       return null;
1503     return comment_textarea;
1504   }
1505
1506   function extendCommentContextUp(key_target) {
1507     var comment_textarea = commentTextareaForKeyTarget(key_target);
1508     if (!comment_textarea)
1509       return;
1510
1511     var comment_base_line = comment_textarea.attr('data-comment-for');
1512     var diff_section = diffSectionFor(comment_textarea);
1513     var lines = $('.Line', diff_section);
1514     for (var i = 0; i < lines.length - 1; i++) {
1515       if (hasDataCommentBaseLine(lines[i + 1], comment_base_line)) {
1516         addDataCommentBaseLine(lines[i], comment_base_line);
1517         break;
1518       }
1519     }
1520   }
1521
1522   function shrinkCommentContextDown(key_target) {
1523     var comment_textarea = commentTextareaForKeyTarget(key_target);
1524     if (!comment_textarea)
1525       return;
1526
1527     var comment_base_line = comment_textarea.attr('data-comment-for');
1528     var diff_section = diffSectionFor(comment_textarea);
1529     var lines = contextLinesFor(comment_base_line, diff_section);
1530     if (lines.size() > 1)
1531       removeDataCommentBaseLine(lines[0], comment_base_line);
1532   }
1533
1534   function handleModifyContextKey(e) {
1535     var handled = false;
1536
1537     if (e.shiftKey && e.ctrlKey) {
1538       switch (e.keyCode) {
1539       case KEY_CODE.up:
1540         extendCommentContextUp(e.target);
1541         handled = true;
1542         break;
1543
1544       case KEY_CODE.down:
1545         shrinkCommentContextDown(e.target);
1546         handled = true;
1547         break;
1548       }
1549     }
1550
1551     if (handled)
1552       e.preventDefault();
1553
1554     return handled;
1555   }
1556
1557   $('textarea').live('keydown', function(e) {
1558     if (handleModifyContextKey(e))
1559       return;
1560
1561     if (e.keyCode == KEY_CODE.escape)
1562       handleEscapeKeyInTextarea(this);
1563   });
1564
1565   $('body').live('keydown', function(e) {
1566     // FIXME: There's got to be a better way to avoid seeing these keypress
1567     // events.
1568     if (e.target.nodeName == 'TEXTAREA')
1569       return;
1570
1571     // Don't want to override browser shortcuts like ctrl+r.
1572     if (e.metaKey || e.ctrlKey)
1573       return;
1574
1575     if (handleModifyContextKey(e))
1576       return;
1577
1578     var handled = false;
1579     switch (e.keyCode) {
1580     case KEY_CODE.r:
1581       $('.review select').focus();
1582       handled = true;
1583       break;
1584
1585     case KEY_CODE.n:
1586       handled = focusNext(isComment, DIRECTION.FORWARD);
1587       break;
1588
1589     case KEY_CODE.p:
1590       handled = focusNext(isComment, DIRECTION.BACKWARD);
1591       break;
1592
1593     case KEY_CODE.j:
1594       if (e.shiftKey)
1595         handled = focusNext(isLine, DIRECTION.FORWARD);
1596       else
1597         handled = focusNext(isDiffBlock, DIRECTION.FORWARD);
1598       break;
1599
1600     case KEY_CODE.k:
1601       if (e.shiftKey)
1602         handled = focusNext(isLine, DIRECTION.BACKWARD);
1603       else
1604         handled = focusNext(isDiffBlock, DIRECTION.BACKWARD);
1605       break;
1606       
1607     case KEY_CODE.enter:
1608       handled = handleEnterKey();
1609       break;
1610       
1611     case KEY_CODE.escape:
1612       hideMoreHelp();
1613       handled = true;
1614       break;
1615     }
1616     
1617     if (handled)
1618       e.preventDefault();
1619   });
1620   
1621   function handleEscapeKeyInTextarea(textarea) {
1622     var comment = $(textarea).parents('.comment');
1623     if (comment.size())
1624       acceptComment(comment);
1625
1626     textarea.blur();
1627     document.body.focus();
1628   }
1629   
1630   function handleEnterKey() {
1631     if (document.activeElement.nodeName == 'BODY')
1632       return;
1633
1634     var focused = $(document.activeElement);
1635
1636     if (focused.hasClass('frozenComment')) {
1637       unfreezeComment(focused);
1638       return true;
1639     }
1640     
1641     if (focused.hasClass('overallComments')) {
1642       openOverallComments();
1643       focused.find('textarea')[0].focus();
1644       return true;
1645     }
1646     
1647     if (focused.hasClass('previousComment')) {
1648       addCommentField(focused);
1649       return true;
1650     }
1651
1652     var lines = focused.hasClass('Line') ? focused : $('.Line', focused);
1653     var last = lines.last();
1654     if (last.attr('data-has-comment')) {
1655       unfreezeCommentFor(last);
1656       return true;
1657     }
1658
1659     addCommentForLines(lines);
1660     return true;
1661   }
1662
1663   function contextLinesFor(comment_base_lines, file_diff) {
1664     var base_lines = comment_base_lines.split(' ');
1665     return $('div[data-comment-base-line]', file_diff).filter(function() {
1666       return $(this).attr('data-comment-base-line').split(' ').some(function(item) {
1667         return base_lines.indexOf(item) != -1;
1668       });
1669     });
1670   }
1671
1672   function numberFrom(line_id) {
1673     return Number(line_id.replace('line', ''));
1674   }
1675
1676   function trimCommentContextToBefore(line, comment_base_line) {
1677     var line_to_trim_to = numberFrom(line.attr('id'));
1678     contextLinesFor(comment_base_line, fileDiffFor(line)).each(function() {
1679       var id = $(this).attr('id');
1680       if (numberFrom(id) > line_to_trim_to)
1681         return;
1682
1683       if (!$('[data-comment-for=' + comment_base_line + ']').length)
1684         removeDataCommentBaseLine(this, comment_base_line);
1685     });
1686   }
1687
1688   var drag_select_start_index = -1;
1689
1690   function lineOffsetFrom(line, offset) {
1691     var file_diff = line.parents('.FileDiff');
1692     var all_lines = $('.Line', file_diff);
1693     var index = all_lines.index(line);
1694     return $(all_lines[index + offset]);
1695   }
1696
1697   function previousLineFor(line) {
1698     return lineOffsetFrom(line, -1);
1699   }
1700
1701   function nextLineFor(line) {
1702     return lineOffsetFrom(line, 1);
1703   }
1704
1705   $('.resizeHandle').live('mousedown', function(event) {
1706     file_diff_being_resized = $(this).parent('.FileDiff');
1707   });
1708
1709   function generateFileDiffResizeStyleElement() {
1710     // FIXME: Once we support calc, we can replace this with something that uses the attribute value.
1711     var styleText = '';
1712     for (var i = minLeftSideRatio; i <= maxLeftSideRatio; i++) {
1713       // FIXME: Once we support calc, put the resize handle at calc(i% - 5) so it doesn't cover up
1714       // the right-side line numbers.
1715       styleText += '.FileDiff[leftsidewidth="' + i + '"] .resizeHandle {' +
1716         'left: ' + i + '%' +
1717       '}' +
1718       '.FileDiff[leftsidewidth="' + i + '"] .LineSide:first-child,' +
1719       '.FileDiff[leftsidewidth="' + i + '"].sidebyside .DiffBlockPart.remove {' +
1720         'width:' + i + '%;' +
1721       '}' +
1722       '.FileDiff[leftsidewidth="' + i + '"] .LineSide:last-child,' +
1723       '.FileDiff[leftsidewidth="' + i + '"].sidebyside .DiffBlockPart.add {' +
1724         'width:' + (100 - i) + '%;' +
1725       '}';
1726     }
1727     var styleElement = document.createElement('style');
1728     styleElement.innerText = styleText;
1729     document.head.appendChild(styleElement);
1730   }
1731
1732   $(document).bind('mousemove', function(event) {
1733     if (!file_diff_being_resized)
1734       return;
1735
1736     var ratio = event.pageX / window.innerWidth;
1737     var percentage = Math.floor(ratio * 100);
1738     if (percentage < minLeftSideRatio)
1739       percentage = minLeftSideRatio;
1740     if (percentage > maxLeftSideRatio)
1741       percentage = maxLeftSideRatio;
1742     file_diff_being_resized.attr('leftsidewidth', percentage);
1743     event.preventDefault();
1744   });
1745
1746   $(document).bind('mouseup', function(event) {
1747     file_diff_being_resized = null;
1748     processSelectedLines();
1749   });
1750
1751   $('.lineNumber').live('click', function(e) {
1752     var line = lineFromLineDescendant($(this));
1753     if (line.hasClass('commentContext'))
1754       trimCommentContextToBefore(previousLineFor(line), line.attr('data-comment-base-line'));
1755     else if (e.shiftKey)
1756       extendCommentContextTo(line);
1757   }).live('mousedown', function(e) {
1758     // preventDefault to avoid selecting text when dragging to select comment context lines.
1759     // FIXME: should we use user-modify CSS instead?
1760     e.preventDefault();
1761     if (e.shiftKey)
1762       return;
1763
1764     var line = lineFromLineDescendant($(this));
1765     drag_select_start_index = numberFrom(line.attr('id'));
1766     line.addClass('selected');
1767   });
1768
1769   $('.LineContainer').live('mouseenter', function(e) {
1770     if (drag_select_start_index == -1 || e.shiftKey)
1771       return;
1772     selectToLineContainer(this);
1773   }).live('mouseup', function(e) {
1774     if (drag_select_start_index == -1 || e.shiftKey)
1775       return;
1776
1777     selectToLineContainer(this);
1778     processSelectedLines();
1779   });
1780
1781   function extendCommentContextTo(line) {
1782     var diff_section = diffSectionFor(line);
1783     var lines = $('.Line', diff_section);
1784     var lines_to_modify = [];
1785     var have_seen_start_line = false;
1786     var data_comment_base_line = null;
1787     lines.each(function() {
1788       if (data_comment_base_line)
1789         return;
1790
1791       have_seen_start_line = have_seen_start_line || this == line[0];
1792       
1793       if (have_seen_start_line) {
1794         if ($(this).hasClass('commentContext'))
1795           data_comment_base_line = $(this).attr('data-comment-base-line');
1796         else
1797           lines_to_modify.push(this);
1798       }
1799     });
1800     
1801     // There is no comment context to extend.
1802     if (!data_comment_base_line)
1803       return;
1804     
1805     $(lines_to_modify).each(function() {
1806       $(this).addClass('commentContext');
1807       $(this).attr('data-comment-base-line', data_comment_base_line);
1808     });
1809   }
1810
1811   function selectTo(focus_index) {
1812     var selected = $('.selected').removeClass('selected');
1813     var is_backward = drag_select_start_index > focus_index;
1814     var current_index = is_backward ? focus_index : drag_select_start_index;
1815     var last_index = is_backward ? drag_select_start_index : focus_index;
1816     while (current_index <= last_index) {
1817       $('#line' + current_index).addClass('selected')
1818       current_index++;
1819     }
1820   }
1821
1822   function selectToLineContainer(line_container) {
1823     var line = lineFromLineContainer(line_container);
1824
1825     // Ensure that the selected lines are all contained in the same DiffSection.
1826     var selected_lines = $('.selected');
1827     var selected_diff_section = diffSectionFor(selected_lines.first());
1828     var new_diff_section = diffSectionFor(line);
1829     if (new_diff_section[0] != selected_diff_section[0]) {
1830       var lines = $('.Line', selected_diff_section);
1831       if (numberFrom(selected_lines.first().attr('id')) == drag_select_start_index)
1832         line = lines.last();
1833       else
1834         line = lines.first();
1835     }
1836     
1837     selectTo(numberFrom(line.attr('id')));
1838   }
1839
1840   function processSelectedLines() {
1841     drag_select_start_index = -1;
1842     addCommentForLines($('.selected'));
1843   }
1844   
1845   function addCommentForLines(lines) {    
1846     if (!lines.size())
1847       return;
1848
1849     var already_has_comment = lines.last().hasClass('commentContext');
1850
1851     var comment_base_line;
1852     if (already_has_comment)
1853       comment_base_line = lines.last().attr('data-comment-base-line');
1854     else {
1855       var last = lineFromLineDescendant(lines.last());
1856       addCommentFor($(last));
1857       comment_base_line = last.attr('id');
1858     }
1859
1860     lines.each(function() {
1861       addDataCommentBaseLine(this, comment_base_line);
1862       $(this).removeClass('selected');
1863     });
1864
1865     saveDraftComments();
1866   }
1867
1868   function hasDataCommentBaseLine(line, id) {
1869     var val = $(line).attr('data-comment-base-line');
1870     if (!val)
1871       return false;
1872
1873     var parts = val.split(' ');
1874     for (var i = 0; i < parts.length; i++) {
1875       if (parts[i] == id)
1876         return true;
1877     }
1878     return false;
1879   }
1880
1881   function addDataCommentBaseLine(line, id) {
1882     $(line).addClass('commentContext');
1883     if (hasDataCommentBaseLine(line, id))
1884       return;
1885
1886     var val = $(line).attr('data-comment-base-line');
1887     var parts = val ? val.split(' ') : [];
1888     parts.push(id);
1889     $(line).attr('data-comment-base-line', parts.join(' '));
1890   }
1891
1892   function removeDataCommentBaseLine(line, comment_base_lines) {
1893     var val = $(line).attr('data-comment-base-line');
1894     if (!val)
1895       return;
1896
1897     var base_lines = comment_base_lines.split(' ');
1898     var parts = val.split(' ');
1899     var new_parts = [];
1900     for (var i = 0; i < parts.length; i++) {
1901       if (base_lines.indexOf(parts[i]) == -1)
1902         new_parts.push(parts[i]);
1903     }
1904
1905     var new_comment_base_line = new_parts.join(' ');
1906     if (new_comment_base_line)
1907       $(line).attr('data-comment-base-line', new_comment_base_line);
1908     else {
1909       $(line).removeAttr('data-comment-base-line');
1910       $(line).removeClass('commentContext');
1911     }
1912   }
1913
1914   function lineFromLineDescendant(descendant) {
1915     return descendant.hasClass('Line') ? descendant : descendant.parents('.Line');
1916   }
1917
1918   function lineContainerFromDescendant(descendant) {
1919     return descendant.hasClass('LineContainer') ? descendant : descendant.parents('.LineContainer');
1920   }
1921
1922   function lineFromLineContainer(lineContainer) {
1923     var line = $(lineContainer);
1924     if (!line.hasClass('Line'))
1925       line = $('.Line', line);
1926     return line;
1927   }
1928
1929   function contextSnippetFor(line, indent) {
1930     var snippets = []
1931     contextLinesFor(line.attr('id'), fileDiffFor(line)).each(function() {
1932       var action = ' ';
1933       if ($(this).hasClass('add'))
1934         action = '+';
1935       else if ($(this).hasClass('remove'))
1936         action = '-';
1937       snippets.push(indent + action + textContentsFor(this));
1938     });
1939     return snippets.join('\n');
1940   }
1941
1942   function fileNameFor(line) {
1943     return fileDiffFor(line).find('h1').text();
1944   }
1945
1946   function indentFor(depth) {
1947     return (new Array(depth + 1)).join('>') + ' ';
1948   }
1949
1950   function snippetFor(line, indent) {
1951     var file_name = fileNameFor(line);
1952     var line_number = line.hasClass('remove') ? '-' + fromLineNumber(line[0]) : toLineNumber(line[0]);
1953     return indent + file_name + ':' + line_number + '\n' + contextSnippetFor(line, indent);
1954   }
1955
1956   function quotePreviousComments(comments) {
1957     var quoted_comments = [];
1958     var depth = comments.size();
1959     comments.each(function() {
1960       var indent = indentFor(depth--);
1961       var text = $(this).children('.content').text();
1962       quoted_comments.push(indent + '\n' + indent + text.split('\n').join('\n' + indent));
1963     });
1964     return quoted_comments.join('\n');
1965   }
1966
1967   $('#comment_form .winter').live('click', hideCommentForm);
1968
1969   function fillInReviewForm() {
1970     var comments_in_context = []
1971     forEachLine(function(line) {
1972       if (line.attr('data-has-comment') != 'true')
1973         return;
1974       var comment = findCommentBlockFor(line).children('textarea').val().trim();
1975       if (comment == '')
1976         return;
1977       var previous_comments = previousCommentsFor(line);
1978       var snippet = snippetFor(line, indentFor(previous_comments.size() + 1));
1979       var quoted_comments = quotePreviousComments(previous_comments);
1980       var comment_with_context = [];
1981       comment_with_context.push(snippet);
1982       if (quoted_comments != '')
1983         comment_with_context.push(quoted_comments);
1984       comment_with_context.push('\n' + comment);
1985       comments_in_context.push(comment_with_context.join('\n'));
1986     });
1987     var comment = $('.overallComments textarea').val().trim();
1988     if (comment != '')
1989       comment += '\n\n';
1990     comment += comments_in_context.join('\n\n');
1991     if (comments_in_context.length > 0)
1992       comment = 'View in context: ' + window.location + '\n\n' + comment;
1993     var review_form = $('#reviewform').contents();
1994     review_form.find('#comment').val(comment);
1995     review_form.find('#flags select').each(function() {
1996       var control = findControlForFlag(this);
1997       if (!control.size())
1998         return;
1999       $(this).attr('selectedIndex', control.attr('selectedIndex'));
2000     });
2001   }
2002
2003   function showCommentForm() {
2004     $('#comment_form').removeClass('inactive');
2005     $('#reviewform').contents().find('#submitBtn').focus();
2006   }
2007   
2008   function hideCommentForm() {
2009     $('#comment_form').addClass('inactive');
2010     
2011     // Make sure the top document has focus so key events don't keep going to the review form.
2012     document.body.tabIndex = -1;
2013     document.body.focus();
2014   }
2015
2016   $('#preview_comments').live('click', function() {
2017     fillInReviewForm();
2018     showCommentForm();
2019   });
2020
2021   $('#post_comments').live('click', function() {
2022     fillInReviewForm();
2023     $('#reviewform').contents().find('form').submit();
2024   });
2025   
2026   if (CODE_REVIEW_UNITTEST) {
2027     window.DraftCommentSaver = DraftCommentSaver;
2028     window.addPreviousComment = addPreviousComment;
2029     window.tracLinks = tracLinks;
2030     window.crawlDiff = crawlDiff;
2031     window.convertAllFileDiffs = convertAllFileDiffs;
2032     window.displayPreviousComments = displayPreviousComments;
2033     window.discardComment = discardComment;
2034     window.addCommentField = addCommentField;
2035     window.acceptComment = acceptComment;
2036     window.appendToolbar = appendToolbar;
2037     window.eraseDraftComments = eraseDraftComments;
2038     window.unfreezeComment = unfreezeComment;
2039     window.g_draftCommentSaver = g_draftCommentSaver;
2040     window.isChangeLog = isChangeLog;
2041   } else {
2042     $(document).ready(handleDocumentReady)
2043   }
2044 })();