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