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