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