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