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