cfaecfac756d53a5a397411c8a49202a9058621f
[WebKit-https.git] / BugsSite / 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
25 (function() {
26   function determineAttachmentID() {
27     try {
28       return /id=(\d+)/.exec(window.location.search)[1]
29     } catch (ex) {
30       return;
31     }
32   }
33
34   // Attempt to activate only in the "Review Patch" context.
35   if (window.top != window)
36     return;
37   if (!window.location.search.match(/action=review/))
38     return;
39   var attachment_id = determineAttachmentID();
40   if (!attachment_id)
41     return;
42
43   var next_line_id = 0;
44
45   function idForLine(number) {
46     return 'line' + number;
47   }
48
49   function nextLineID() {
50     return idForLine(next_line_id++);
51   }
52
53   function forEachLine(callback) {
54     for (var i = 0; i < next_line_id; ++i) {
55       callback($('#' + idForLine(i)));
56     }
57   }
58
59   function idify() {
60     this.id = nextLineID();
61   }
62
63   function hoverify() {
64     $(this).hover(function() {
65       $(this).addClass('hot');
66     },
67     function () {
68       $(this).removeClass('hot');
69     });
70   }
71
72   function previousCommentsFor(line) {
73     var comments = [];
74     var position = line;
75     while (position.next() && position.next().hasClass('previousComment')) {
76       position = position.next();
77       comments.push(position.get());
78     }
79     return $(comments);
80   }
81
82   function findCommentPositionFor(line) {
83     var position = line;
84     while (position.next() && position.next().hasClass('previousComment'))
85       position = position.next();
86     return position;
87   }
88
89   function findCommentBlockFor(line) {
90     var comment_block = findCommentPositionFor(line).next();
91     if (!comment_block.hasClass('comment'))
92       return;
93     return comment_block;
94   }
95
96   function insertCommentFor(line, block) {
97     findCommentPositionFor(line).after(block);
98   }
99
100   function addCommentFor(line) {
101     if (line.attr('data-has-comment')) {
102       // FIXME: This query is overly complex because we place comment blocks
103       // after Lines.  Instead, comment blocks should be children of Lines.
104       findCommentPositionFor(line).next().next().filter('.frozenComment').each(unfreezeComment);
105       return;
106     }
107     line.attr('data-has-comment', 'true');
108     line.addClass('commentContext');
109
110     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>');
111     insertCommentFor(line, comment_block);
112     comment_block.hide().slideDown('fast', function() {
113       $(this).children('textarea').focus();
114     });
115   }
116
117   function addCommentField() {
118     var id = $(this).attr('data-comment-for');
119     if (!id)
120       id = this.id;
121     addCommentFor($('#' + id));
122   }
123
124   var files = {}
125
126   function addPreviousComment(line, author, comment_text) {
127     var comment_block = $('<div data-comment-for="' + line.attr('id') + '" class="previousComment"></div>');
128     var author_block = $('<div class="author"></div>').text(author + ':');
129     var text_block = $('<div class="content"></div>').text(comment_text);
130     comment_block.append(author_block).append(text_block).each(hoverify).click(addCommentField);
131     insertCommentFor(line, comment_block);
132   }
133
134   function displayPreviousComments(comments) {
135     for (var i = 0; i < comments.length; ++i) {
136       var author = comments[i].author;
137       var file_name = comments[i].file_name;
138       var line_number = comments[i].line_number;
139       var comment_text = comments[i].comment_text;
140
141       var file = files[file_name];
142
143       var query = '.Line .to';
144       if (line_number[0] == '-') {
145         // The line_number represent a removal.  We need to adjust the query to
146         // look at the "from" lines.
147         query = '.Line .from';
148         // Trim off the '-' control character.
149         line_number = line_number.substr(1);
150       }
151
152       $(file).find(query).each(function() {
153         if ($(this).text() != line_number)
154           return;
155         var line = $(this).parent();
156         addPreviousComment(line, author, comment_text);
157       });
158     }
159     if (comments.length == 0)
160       return;
161     descriptor = comments.length + ' comment';
162     if (comments.length > 1)
163       descriptor += 's';
164     $('.message .commentStatus').text('This patch has ' + descriptor + '.  Scroll through them with the "n" and "p" keys.');
165   }
166
167   function scanForComments(author, text) {
168     var comments = []
169     var lines = text.split('\n');
170     for (var i = 0; i < lines.length; ++i) {
171       var parts = lines[i].match(/^([> ]+)([^:]+):(-?\d+)$/);
172       if (!parts)
173         continue;
174       var quote_markers = parts[1];
175       var file_name = parts[2];
176       var line_number = parts[3];
177       if (!file_name in files)
178         continue;
179       while (i < lines.length && lines[i].length > 0 && lines[i][0] == '>')
180         ++i;
181       var comment_lines = [];
182       while (i < lines.length && (lines[i].length == 0 || lines[i][0] != '>')) {
183         comment_lines.push(lines[i]);
184         ++i;
185       }
186       --i; // Decrement i because the for loop will increment it again in a second.
187       var comment_text = comment_lines.join('\n').trim();
188       comments.push({
189         'author': author,
190         'file_name': file_name,
191         'line_number': line_number,
192         'comment_text': comment_text
193       });
194     }
195     return comments;
196   }
197
198   function isReviewFlag(select) {
199     return $(select).attr('title') == 'Request for patch review.';
200   }
201
202   function isCommitQueueFlag(select) {
203     return $(select).attr('title').match(/commit-queue/);
204   }
205
206   function findControlForFlag(select) {
207     if (isReviewFlag(select))
208       return $('#toolbar .review select');
209     else if (isCommitQueueFlag(select))
210       return $('#toolbar .commitQueue select');
211     return $();
212   }
213
214   function addFlagsForAttachment(details) {
215     var flag_control = "<select><option></option><option>?</option><option>+</option><option>-</option></select>";
216     $('#flagContainer').append(
217       $('<span class="review"> r: ' + flag_control + '</span>')).append(
218       $('<span class="commitQueue"> cq: ' + flag_control + '</span>'));
219
220     details.find('#flags select').each(function() {
221       var requestee = $(this).parent().siblings('td:first-child').text().trim();
222       if (requestee.length) {
223         // Remove trailing ':'.
224         requestee = requestee.substr(0, requestee.length - 1);
225         requestee = ' (' + requestee + ')';
226       }
227       var control = findControlForFlag(this)
228       control.attr('selectedIndex', $(this).attr('selectedIndex'));
229       control.parent().prepend(requestee);
230     });
231   }
232
233   function fetchHistory() {
234     $.get('attachment.cgi?id=' + attachment_id + '&action=edit', function(data) {
235       var bug_id = /Attachment \d+ Details for Bug (\d+)/.exec(data)[1];
236       $.get('show_bug.cgi?id=' + bug_id, function(data) {
237         var comments = [];
238         $(data).find('.bz_comment').each(function() {
239           var author = $(this).find('.email').text();
240           var text = $(this).find('.bz_comment_text').text();
241           var comment_marker = '(From update of attachment ' + attachment_id + ' .details.)';
242           if (text.match(comment_marker))
243             $.merge(comments, scanForComments(author, text));
244         });
245         displayPreviousComments(comments);
246       });
247
248       var details = $(data);
249       addFlagsForAttachment(details);
250       $('#statusBubbleContainer').append($('<iframe style="margin-top:2px;" class="statusBubble" src="https://webkit-commit-queue.appspot.com/status-bubble/' + attachment_id + '" scrolling="no"></iframe>'));
251       $('#toolbar .bugLink').html('<a href="/show_bug.cgi?id=' + bug_id + '" target="_blank">Bug ' + bug_id + '</a>');
252     });
253   }
254
255   function crawlDiff() {
256     $('.Line').each(idify).each(hoverify);
257     $('.FileDiff').each(function() {
258       var file_name = $(this).children('h1').text();
259       files[file_name] = this;
260     });
261   }
262
263   function openOverallComments(e) {
264     $('.overallComments textarea').addClass('open');
265     $('#statusBubbleContainer').addClass('wrap');
266   }
267
268   $(document).ready(function() {
269     crawlDiff();
270     fetchHistory();
271     $(document.body).prepend('<div id="message"><div class="help">Select line numbers to add a comment.</div><div class="commentStatus"></div></div>');
272     $(document.body).prepend('<div id="toolbar">' +
273         '<div class="overallComments">' +
274             '<textarea placeholder="Overall comments"></textarea>' +
275         '</div>' +
276         '<div>' +
277           '<span id="statusBubbleContainer"></span>' +
278           '<span class="actions">' +
279               '<span class="links"><span class="bugLink"></span></span>' +
280               '<span id="flagContainer"></span>' +
281               '<button id="preview_comments">Preview</button>' +
282               '<button id="post_comments">Publish</button> ' +
283           '</span></div>' +
284         '</div>' +
285         '</div>');
286
287     $('.overallComments textarea').bind('click', openOverallComments);
288
289     $(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>');
290   });
291
292   function discardComment() {
293     var line_id = $(this).parentsUntil('.comment').parent().find('textarea').attr('data-comment-for');
294     var line = $('#' + line_id)
295     findCommentBlockFor(line).slideUp('fast', function() {
296       $(this).remove();
297       line.removeAttr('data-has-comment');
298       trimCommentContextToBefore(line);
299     });
300   }
301
302   function unfreezeComment() {
303     $(this).prev().show();
304     $(this).remove();
305   }
306
307   $('.comment .discard').live('click', discardComment);
308
309   $('.comment .ok').live('click', function() {
310     var comment_textarea = $(this).parentsUntil('.comment').parent().find('textarea');
311     if (comment_textarea.val().trim() == '') {
312       discardComment.call(this);
313       return;
314     }
315     var line_id = comment_textarea.attr('data-comment-for');
316     var line = $('#' + line_id)
317     findCommentBlockFor(line).hide().after($('<div class="frozenComment"></div>').text(comment_textarea.val()));
318   });
319
320   $('.frozenComment').live('click', unfreezeComment);
321
322   function focusOn(comment) {
323     $('.focused').removeClass('focused');
324     if (comment.length == 0)
325       return;
326     $(document).scrollTop(comment.addClass('focused').position().top - window.innerHeight/2);
327   }
328
329   function focusNextComment() {
330     var comments = $('.previousComment');
331     if (comments.length == 0)
332       return;
333     var index = comments.index($('.focused'));
334     // Notice that -1 gets mapped to 0.
335     focusOn($(comments.get(index + 1)));
336   }
337
338   function focusPreviousComment() {
339     var comments = $('.previousComment');
340     if (comments.length == 0)
341       return;
342     var index = comments.index($('.focused'));
343     if (index == -1)
344       index = comments.length;
345     if (index == 0) {
346       focusOn([]);
347       return;
348     }
349     focusOn($(comments.get(index - 1)));
350   }
351
352   var kCharCodeForN = 'n'.charCodeAt(0);
353   var kCharCodeForP = 'p'.charCodeAt(0);
354
355   $('body').live('keypress', function() {
356     // FIXME: There's got to be a better way to avoid seeing these keypress
357     // events.
358     if (event.target.nodeName == 'TEXTAREA')
359       return;
360     if (event.charCode == kCharCodeForN)
361       focusNextComment();
362     else if (event.charCode == kCharCodeForP)
363       focusPreviousComment();
364   });
365
366   function contextLinesFor(line) {
367     var context = [];
368     while (line.hasClass('commentContext')) {
369       $.merge(context, line);
370       line = line.prev();
371     }
372     return $(context.reverse());
373   }
374
375   function trimCommentContextToBefore(line) {
376     while (line.hasClass('commentContext') && line.attr('data-has-comment') != 'true') {
377       line.removeClass('commentContext');
378       line = line.prev();
379     }
380   }
381
382   var in_drag_select = false;
383
384   function stopDragSelect() {
385     $('.selected').removeClass('selected');
386     in_drag_select = false;
387   }
388
389   $('.lineNumber').live('click', function() {
390     var line = $(this).parent();
391     if (line.hasClass('commentContext'))
392       trimCommentContextToBefore(line.prev());
393   }).live('mousedown', function() {
394     in_drag_select = true;
395     $(this).parent().addClass('selected');
396     event.preventDefault();
397   });
398   
399   $('.Line').live('mouseenter', function() {
400     if (!in_drag_select)
401       return;
402
403     var before = $(this).prevUntil('.selected')
404     if (before.prev().hasClass('selected'))
405       before.addClass('selected');
406
407     var after = $(this).nextUntil('.selected')
408     if (after.next().hasClass('selected'))
409       after.addClass('selected');
410
411     $(this).addClass('selected');
412   }).live('mouseup', function() {
413     if (!in_drag_select)
414       return;
415     var selected = $('.selected');
416     var should_add_comment = !selected.last().next().hasClass('commentContext');
417     selected.addClass('commentContext');
418     if (should_add_comment)
419       addCommentFor(selected.last());
420   });
421
422   $('.DiffSection').live('mouseleave', stopDragSelect).live('mouseup', stopDragSelect);
423
424   function contextSnippetFor(line, indent) {
425     var snippets = []
426     contextLinesFor(line).each(function() {
427       var action = ' ';
428       if ($(this).hasClass('add'))
429         action = '+';
430       else if ($(this).hasClass('remove'))
431         action = '-';
432       var text = $(this).children('.text').text();
433       snippets.push(indent + action + text);
434     });
435     return snippets.join('\n');
436   }
437
438   function fileNameFor(line) {
439     return line.parentsUntil('.FileDiff').parent().find('h1').text();
440   }
441
442   function indentFor(depth) {
443     return (new Array(depth + 1)).join('>') + ' ';
444   }
445
446   function snippetFor(line, indent) {
447     var file_name = fileNameFor(line);
448     var line_number = line.hasClass('remove') ? '-' + line.children('.from').text() : line.children('.to').text();
449     return indent + file_name + ':' + line_number + '\n' + contextSnippetFor(line, indent);
450   }
451
452   function quotePreviousComments(comments) {
453     var quoted_comments = [];
454     var depth = comments.size();
455     comments.each(function() {
456       var indent = indentFor(depth--);
457       var text = $(this).children('.content').text();
458       quoted_comments.push(indent + '\n' + indent + text.split('\n').join('\n' + indent));
459     });
460     return quoted_comments.join('\n');
461   }
462
463   $('#comment_form .winter').live('click', function() {
464     $('#comment_form').addClass('inactive');
465   });
466
467   function fillInReviewForm() {
468     var comments_in_context = []
469     forEachLine(function(line) {
470       if (line.attr('data-has-comment') != 'true')
471         return;
472       var comment = findCommentBlockFor(line).children('textarea').val().trim();
473       if (comment == '')
474         return;
475       var previous_comments = previousCommentsFor(line);
476       var snippet = snippetFor(line, indentFor(previous_comments.size() + 1));
477       var quoted_comments = quotePreviousComments(previous_comments);
478       var comment_with_context = [];
479       comment_with_context.push(snippet);
480       if (quoted_comments != '')
481         comment_with_context.push(quoted_comments);
482       comment_with_context.push('\n' + comment);
483       comments_in_context.push(comment_with_context.join('\n'));
484     });
485     var comment = $('.overallComments textarea').val().trim();
486     if (comment != '')
487       comment += '\n\n';
488     comment += comments_in_context.join('\n\n');
489     if (comments_in_context.length > 0)
490       comment = 'View in context: ' + window.location + '\n\n' + comment;
491     var review_form = $('#reviewform').contents();
492     review_form.find('#comment').val(comment);
493     review_form.find('#flags select').each(function() {
494       var control = findControlForFlag(this);
495       if (!control.size())
496         return;
497       $(this).attr('selectedIndex', control.attr('selectedIndex'));
498     });
499   }
500
501   $('#preview_comments').live('click', function() {
502     fillInReviewForm();
503     $('#comment_form').removeClass('inactive');
504   });
505
506   $('#post_comments').live('click', function() {
507     fillInReviewForm();
508     $('#reviewform').contents().find('form').submit();
509   });
510 })();