James Moger
2015-11-22 ed552ba47c02779c270ffd62841d6d1048dade70
commit | author | age
5e3521 1 /*
JM 2  * Copyright 2014 gitblit.com.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.gitblit.wicket.pages;
17
18 import java.text.DateFormat;
19 import java.text.MessageFormat;
20 import java.text.SimpleDateFormat;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Calendar;
24 import java.util.Collections;
25 import java.util.Date;
26 import java.util.List;
27 import java.util.Map;
28 import java.util.Set;
29 import java.util.TimeZone;
30 import java.util.TreeSet;
31
32 import javax.servlet.http.HttpServletRequest;
33
34 import org.apache.wicket.AttributeModifier;
35 import org.apache.wicket.Component;
36 import org.apache.wicket.MarkupContainer;
37 import org.apache.wicket.PageParameters;
38 import org.apache.wicket.RestartResponseException;
39 import org.apache.wicket.ajax.AjaxRequestTarget;
40 import org.apache.wicket.behavior.SimpleAttributeModifier;
41 import org.apache.wicket.markup.html.basic.Label;
42 import org.apache.wicket.markup.html.image.ContextImage;
43 import org.apache.wicket.markup.html.link.BookmarkablePageLink;
44 import org.apache.wicket.markup.html.link.ExternalLink;
45 import org.apache.wicket.markup.html.panel.Fragment;
46 import org.apache.wicket.markup.repeater.Item;
47 import org.apache.wicket.markup.repeater.data.DataView;
48 import org.apache.wicket.markup.repeater.data.ListDataProvider;
49 import org.apache.wicket.model.Model;
50 import org.apache.wicket.protocol.http.WebRequest;
51 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
52 import org.eclipse.jgit.lib.PersonIdent;
53 import org.eclipse.jgit.lib.Ref;
54 import org.eclipse.jgit.lib.Repository;
55 import org.eclipse.jgit.revwalk.RevCommit;
56
57 import com.gitblit.Constants;
58 import com.gitblit.Constants.AccessPermission;
c211e9 59 import com.gitblit.Constants.AuthorizationControl;
5e3521 60 import com.gitblit.Keys;
JM 61 import com.gitblit.git.PatchsetCommand;
62 import com.gitblit.git.PatchsetReceivePack;
63 import com.gitblit.models.PathModel.PathChangeModel;
64 import com.gitblit.models.RegistrantAccessPermission;
65 import com.gitblit.models.RepositoryModel;
b236ce 66 import com.gitblit.models.RepositoryUrl;
5e3521 67 import com.gitblit.models.SubmoduleModel;
JM 68 import com.gitblit.models.TicketModel;
69 import com.gitblit.models.TicketModel.Change;
70 import com.gitblit.models.TicketModel.CommentSource;
71 import com.gitblit.models.TicketModel.Field;
72 import com.gitblit.models.TicketModel.Patchset;
73 import com.gitblit.models.TicketModel.PatchsetType;
74 import com.gitblit.models.TicketModel.Review;
75 import com.gitblit.models.TicketModel.Score;
76 import com.gitblit.models.TicketModel.Status;
77 import com.gitblit.models.UserModel;
78 import com.gitblit.tickets.TicketIndexer.Lucene;
79 import com.gitblit.tickets.TicketLabel;
80 import com.gitblit.tickets.TicketMilestone;
81 import com.gitblit.tickets.TicketResponsible;
b236ce 82 import com.gitblit.utils.ArrayUtils;
5e3521 83 import com.gitblit.utils.JGitUtils;
JM 84 import com.gitblit.utils.JGitUtils.MergeStatus;
85 import com.gitblit.utils.MarkdownUtils;
86 import com.gitblit.utils.StringUtils;
87 import com.gitblit.utils.TimeUtils;
88 import com.gitblit.wicket.GitBlitWebSession;
fdd82f 89 import com.gitblit.wicket.TicketsUI;
5e3521 90 import com.gitblit.wicket.WicketUtils;
a59232 91 import com.gitblit.wicket.panels.AvatarImage;
5e3521 92 import com.gitblit.wicket.panels.BasePanel.JavascriptTextPrompt;
JM 93 import com.gitblit.wicket.panels.CommentPanel;
94 import com.gitblit.wicket.panels.DiffStatPanel;
95 import com.gitblit.wicket.panels.IconAjaxLink;
96 import com.gitblit.wicket.panels.LinkPanel;
97 import com.gitblit.wicket.panels.ShockWaveComponent;
98 import com.gitblit.wicket.panels.SimpleAjaxLink;
99
100 /**
101  * The ticket page handles viewing and updating a ticket.
102  *
103  * @author James Moger
104  *
105  */
fdd82f 106 public class TicketPage extends RepositoryPage {
5e3521 107
JM 108     static final String NIL = "<nil>";
109
110     static final String ESC_NIL = StringUtils.escapeForHtml(NIL,  false);
111
112     final int avatarWidth = 40;
113
114     final TicketModel ticket;
115
116     public TicketPage(PageParameters params) {
117         super(params);
118
119         final UserModel user = GitBlitWebSession.get().getUser() == null ? UserModel.ANONYMOUS : GitBlitWebSession.get().getUser();
120         final RepositoryModel repository = getRepositoryModel();
121         final String id = WicketUtils.getObject(params);
122         long ticketId = Long.parseLong(id);
123         ticket = app().tickets().getTicket(repository, ticketId);
124
125         if (ticket == null) {
126             // ticket not found
127             throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
128         }
129
130         final List<Change> revisions = new ArrayList<Change>();
131         List<Change> comments = new ArrayList<Change>();
132         List<Change> statusChanges = new ArrayList<Change>();
133         List<Change> discussion = new ArrayList<Change>();
134         for (Change change : ticket.changes) {
135             if (change.hasComment() || (change.isStatusChange() && (change.getStatus() != Status.New))) {
136                 discussion.add(change);
137             }
138             if (change.hasComment()) {
139                 comments.add(change);
140             }
141             if (change.hasPatchset()) {
142                 revisions.add(change);
143             }
144             if (change.isStatusChange() && !change.hasPatchset()) {
145                 statusChanges.add(change);
146             }
147         }
148
149         final Change currentRevision = revisions.isEmpty() ? null : revisions.get(revisions.size() - 1);
150         final Patchset currentPatchset = ticket.getCurrentPatchset();
151
152         /*
153          * TICKET HEADER
154          */
155         String href = urlFor(TicketsPage.class, params).toString();
156         add(new ExternalLink("ticketNumber", href, "#" + ticket.number));
157         Label headerStatus = new Label("headerStatus", ticket.status.toString());
fdd82f 158         WicketUtils.setCssClass(headerStatus, TicketsUI.getLozengeClass(ticket.status, false));
5e3521 159         add(headerStatus);
JM 160         add(new Label("ticketTitle", ticket.title));
161         if (currentPatchset == null) {
162             add(new Label("diffstat").setVisible(false));
163         } else {
164             // calculate the current diffstat of the patchset
165             add(new DiffStatPanel("diffstat", ticket.insertions, ticket.deletions));
166         }
167
168
169         /*
170          * TAB TITLES
171          */
172         add(new Label("commentCount", "" + comments.size()).setVisible(!comments.isEmpty()));
173         add(new Label("commitCount", "" + (currentPatchset == null ? 0 : currentPatchset.commits)).setVisible(currentPatchset != null));
174
175
176         /*
177          * TICKET AUTHOR and DATE (DISCUSSION TAB)
178          */
179         UserModel createdBy = app().users().getUserModel(ticket.createdBy);
180         if (createdBy == null) {
181             add(new Label("whoCreated", ticket.createdBy));
182         } else {
183             add(new LinkPanel("whoCreated", null, createdBy.getDisplayName(),
184                     UserPage.class, WicketUtils.newUsernameParameter(createdBy.username)));
185         }
186
187         if (ticket.isProposal()) {
188             // clearly indicate this is a change ticket
189             add(new Label("creationMessage", getString("gb.proposedThisChange")));
190         } else {
191             // standard ticket
192             add(new Label("creationMessage", getString("gb.createdThisTicket")));
193         }
194
195         String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");
196         String timestampFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy");
197         final TimeZone timezone = getTimeZone();
198         final DateFormat df = new SimpleDateFormat(dateFormat);
199         df.setTimeZone(timezone);
200         final DateFormat tsf = new SimpleDateFormat(timestampFormat);
201         tsf.setTimeZone(timezone);
202         final Calendar cal = Calendar.getInstance(timezone);
203
204         String fuzzydate;
205         TimeUtils tu = getTimeUtils();
206         Date createdDate = ticket.created;
207         if (TimeUtils.isToday(createdDate, timezone)) {
208             fuzzydate = tu.today();
209         } else if (TimeUtils.isYesterday(createdDate, timezone)) {
210             fuzzydate = tu.yesterday();
211         } else {
212             // calculate a fuzzy time ago date
213             cal.setTime(createdDate);
214             cal.set(Calendar.HOUR_OF_DAY, 0);
215             cal.set(Calendar.MINUTE, 0);
216             cal.set(Calendar.SECOND, 0);
217             cal.set(Calendar.MILLISECOND, 0);
218             createdDate = cal.getTime();
219             fuzzydate = getTimeUtils().timeAgo(createdDate);
220         }
221         Label when = new Label("whenCreated", fuzzydate + ", " + df.format(createdDate));
222         WicketUtils.setHtmlTooltip(when, tsf.format(ticket.created));
223         add(when);
224
225         String exportHref = urlFor(ExportTicketPage.class, params).toString();
226         add(new ExternalLink("exportJson", exportHref, "json"));
227
228
229         /*
230          * RESPONSIBLE (DISCUSSION TAB)
231          */
232         if (StringUtils.isEmpty(ticket.responsible)) {
233             add(new Label("responsible"));
234         } else {
235             UserModel responsible = app().users().getUserModel(ticket.responsible);
236             if (responsible == null) {
237                 add(new Label("responsible", ticket.responsible));
238             } else {
239                 add(new LinkPanel("responsible", null, responsible.getDisplayName(),
240                         UserPage.class, WicketUtils.newUsernameParameter(responsible.username)));
241             }
242         }
243
244         /*
245          * MILESTONE PROGRESS (DISCUSSION TAB)
246          */
247         if (StringUtils.isEmpty(ticket.milestone)) {
248             add(new Label("milestone"));
249         } else {
250             // link to milestone query
270e9e 251             TicketMilestone tm = app().tickets().getMilestone(repository, ticket.milestone);
JM 252             if (tm == null) {
253                 tm = new TicketMilestone(ticket.milestone);
254             }
e03317 255             PageParameters milestoneParameters;
270e9e 256             if (tm.isOpen()) {
e03317 257                 milestoneParameters = WicketUtils.newOpenTicketsParameter(repositoryName);
JM 258             } else {
259                 milestoneParameters = WicketUtils.newRepositoryParameter(repositoryName);
260             }
5e3521 261             milestoneParameters.put(Lucene.milestone.name(), ticket.milestone);
JM 262             int progress = 0;
263             int open = 0;
264             int closed = 0;
270e9e 265             if (tm != null) {
JM 266                 progress = tm.getProgress();
267                 open = tm.getOpenTickets();
268                 closed = tm.getClosedTickets();
5e3521 269             }
JM 270
271             Fragment milestoneProgress = new Fragment("milestone", "milestoneProgressFragment", this);
272             milestoneProgress.add(new LinkPanel("link", null, ticket.milestone, TicketsPage.class, milestoneParameters));
273             Label label = new Label("progress");
274             WicketUtils.setCssStyle(label, "width:" + progress + "%;");
275             milestoneProgress.add(label);
9f50fd 276             WicketUtils.setHtmlTooltip(milestoneProgress, MessageFormat.format(getString("gb.milestoneProgress"), open, closed));
5e3521 277             add(milestoneProgress);
JM 278         }
279
280
281         /*
282          * TICKET DESCRIPTION (DISCUSSION TAB)
283          */
284         String desc;
285         if (StringUtils.isEmpty(ticket.body)) {
286             desc = getString("gb.noDescriptionGiven");
287         } else {
d07b16 288             String bugtraq = bugtraqProcessor().processText(getRepository(), repositoryName, ticket.body);
a59627 289             String html = MarkdownUtils.transformGFM(app().settings(), bugtraq, ticket.repository);
JM 290             String safeHtml = app().xssFilter().relaxed(html);
291             desc = safeHtml;
5e3521 292         }
JM 293         add(new Label("ticketDescription", desc).setEscapeModelStrings(false));
294
295
296         /*
297          * PARTICIPANTS (DISCUSSION TAB)
298          */
299         if (app().settings().getBoolean(Keys.web.allowGravatar, true)) {
300             // gravatar allowed
301             List<String> participants = ticket.getParticipants();
302             add(new Label("participantsLabel", MessageFormat.format(getString(participants.size() > 1 ? "gb.nParticipants" : "gb.oneParticipant"),
303                     "<b>" + participants.size() + "</b>")).setEscapeModelStrings(false));
304             ListDataProvider<String> participantsDp = new ListDataProvider<String>(participants);
305             DataView<String> participantsView = new DataView<String>("participants", participantsDp) {
306                 private static final long serialVersionUID = 1L;
307
308                 @Override
309                 public void populateItem(final Item<String> item) {
310                     String username = item.getModelObject();
311                     UserModel user = app().users().getUserModel(username);
312                     if (user == null) {
313                         user = new UserModel(username);
314                     }
b57b9e 315                     item.add(new AvatarImage("participant", user.getDisplayName(),
5e3521 316                             user.emailAddress, null, 25, true));
JM 317                 }
318             };
319             add(participantsView);
320         } else {
321             // gravatar prohibited
322             add(new Label("participantsLabel").setVisible(false));
323             add(new Label("participants").setVisible(false));
324         }
325
326
327         /*
328          * LARGE STATUS INDICATOR WITH ICON (DISCUSSION TAB->SIDE BAR)
329          */
330         Fragment ticketStatus = new Fragment("ticketStatus", "ticketStatusFragment", this);
fdd82f 331         Label ticketIcon = TicketsUI.getStateIcon("ticketIcon", ticket);
5e3521 332         ticketStatus.add(ticketIcon);
JM 333         ticketStatus.add(new Label("ticketStatus", ticket.status.toString()));
fdd82f 334         WicketUtils.setCssClass(ticketStatus, TicketsUI.getLozengeClass(ticket.status, false));
5e3521 335         add(ticketStatus);
JM 336
337
338         /*
339          * UPDATE FORM (DISCUSSION TAB)
340          */
7ca053 341         if (user.canEdit(ticket, repository) && app().tickets().isAcceptingTicketUpdates(repository)) {
cc1c3f 342             if (user.canAdmin(ticket, repository) && ticket.isOpen()) {
85775a 343                 /*
JM 344                  * OPEN TICKET
345                  */
346                 Fragment controls = new Fragment("controls", "openControlsFragment", this);
5e3521 347
85775a 348                 /*
JM 349                  * STATUS
350                  */
351                 List<Status> choices = new ArrayList<Status>();
352                 if (ticket.isProposal()) {
353                     choices.addAll(Arrays.asList(TicketModel.Status.proposalWorkflow));
354                 } else if (ticket.isBug()) {
355                     choices.addAll(Arrays.asList(TicketModel.Status.bugWorkflow));
356                 } else {
357                     choices.addAll(Arrays.asList(TicketModel.Status.requestWorkflow));
5e3521 358                 }
85775a 359                 choices.remove(ticket.status);
5e3521 360
85775a 361                 ListDataProvider<Status> workflowDp = new ListDataProvider<Status>(choices);
JM 362                 DataView<Status> statusView = new DataView<Status>("newStatus", workflowDp) {
363                     private static final long serialVersionUID = 1L;
5e3521 364
85775a 365                     @Override
JM 366                     public void populateItem(final Item<Status> item) {
367                         SimpleAjaxLink<Status> link = new SimpleAjaxLink<Status>("link", item.getModel()) {
5e3521 368
85775a 369                             private static final long serialVersionUID = 1L;
5e3521 370
85775a 371                             @Override
JM 372                             public void onClick(AjaxRequestTarget target) {
373                                 Status status = getModel().getObject();
374                                 Change change = new Change(user.username);
375                                 change.setField(Field.status, status);
376                                 if (!ticket.isWatching(user.username)) {
377                                     change.watch(user.username);
5e3521 378                                 }
85775a 379                                 TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
JM 380                                 app().tickets().createNotifier().sendMailing(update);
47a544 381                                 redirectTo(TicketsPage.class, getPageParameters());
5e3521 382                             }
85775a 383                         };
fdd82f 384                         String css = TicketsUI.getStatusClass(item.getModel().getObject());
85775a 385                         WicketUtils.setCssClass(link, css);
JM 386                         item.add(link);
387                     }
388                 };
389                 controls.add(statusView);
5e3521 390
85775a 391                 /*
JM 392                  * RESPONSIBLE LIST
393                  */
394                 Set<String> userlist = new TreeSet<String>(ticket.getParticipants());
c211e9 395                 if (UserModel.ANONYMOUS.canPush(getRepositoryModel())
JM 396                         || AuthorizationControl.AUTHENTICATED == getRepositoryModel().authorizationControl) {
397                     //     authorization is ANONYMOUS or AUTHENTICATED (i.e. all users can be set responsible)
398                     userlist.addAll(app().users().getAllUsernames());
399                 } else {
400                     // authorization is by NAMED users (users with PUSH permission can be set responsible)
401                     for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
805468 402                         if (rp.permission.atLeast(AccessPermission.PUSH)) {
c211e9 403                             userlist.add(rp.registrant);
JM 404                         }
5e3521 405                     }
JM 406                 }
85775a 407                 List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
JM 408                 if (!StringUtils.isEmpty(ticket.responsible)) {
409                     // exclude the current responsible
410                     userlist.remove(ticket.responsible);
5e3521 411                 }
85775a 412                 for (String username : userlist) {
JM 413                     UserModel u = app().users().getUserModel(username);
414                     if (u != null) {
415                         responsibles.add(new TicketResponsible(u));
416                     }
417                 }
418                 Collections.sort(responsibles);
419                 responsibles.add(new TicketResponsible(ESC_NIL, "", ""));
420                 ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);
421                 DataView<TicketResponsible> responsibleView = new DataView<TicketResponsible>("newResponsible", responsibleDp) {
422                     private static final long serialVersionUID = 1L;
5e3521 423
85775a 424                     @Override
JM 425                     public void populateItem(final Item<TicketResponsible> item) {
426                         SimpleAjaxLink<TicketResponsible> link = new SimpleAjaxLink<TicketResponsible>("link", item.getModel()) {
5e3521 427
85775a 428                             private static final long serialVersionUID = 1L;
JM 429
430                             @Override
431                             public void onClick(AjaxRequestTarget target) {
432                                 TicketResponsible responsible = getModel().getObject();
433                                 Change change = new Change(user.username);
434                                 change.setField(Field.responsible, responsible.username);
435                                 if (!StringUtils.isEmpty(responsible.username)) {
436                                     if (!ticket.isWatching(responsible.username)) {
437                                         change.watch(responsible.username);
438                                     }
439                                 }
440                                 if (!ticket.isWatching(user.username)) {
441                                     change.watch(user.username);
442                                 }
443                                 TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
444                                 app().tickets().createNotifier().sendMailing(update);
47a544 445                                 redirectTo(TicketsPage.class, getPageParameters());
85775a 446                             }
JM 447                         };
448                         item.add(link);
449                     }
450                 };
451                 controls.add(responsibleView);
452
453                 /*
454                  * MILESTONE LIST
455                  */
456                 List<TicketMilestone> milestones = app().tickets().getMilestones(repository, Status.Open);
457                 if (!StringUtils.isEmpty(ticket.milestone)) {
458                     for (TicketMilestone milestone : milestones) {
459                         if (milestone.name.equals(ticket.milestone)) {
460                             milestones.remove(milestone);
461                             break;
462                         }
463                     }
464                 }
465                 milestones.add(new TicketMilestone(ESC_NIL));
466                 ListDataProvider<TicketMilestone> milestoneDp = new ListDataProvider<TicketMilestone>(milestones);
467                 DataView<TicketMilestone> milestoneView = new DataView<TicketMilestone>("newMilestone", milestoneDp) {
468                     private static final long serialVersionUID = 1L;
469
470                     @Override
471                     public void populateItem(final Item<TicketMilestone> item) {
472                         SimpleAjaxLink<TicketMilestone> link = new SimpleAjaxLink<TicketMilestone>("link", item.getModel()) {
473
474                             private static final long serialVersionUID = 1L;
475
476                             @Override
477                             public void onClick(AjaxRequestTarget target) {
478                                 TicketMilestone milestone = getModel().getObject();
479                                 Change change = new Change(user.username);
480                                 if (NIL.equals(milestone.name) || ESC_NIL.equals(milestone.name)) {
481                                     change.setField(Field.milestone, "");
482                                 } else {
483                                     change.setField(Field.milestone, milestone.name);
484                                 }
485                                 if (!ticket.isWatching(user.username)) {
486                                     change.watch(user.username);
487                                 }
488                                 TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
489                                 app().tickets().createNotifier().sendMailing(update);
47a544 490                                 redirectTo(TicketsPage.class, getPageParameters());
85775a 491                             }
JM 492                         };
493                         item.add(link);
494                     }
495                 };
496                 controls.add(milestoneView);
497
498                 String editHref = urlFor(EditTicketPage.class, params).toString();
499                 controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));
500
501                 add(controls);
502             } else {
503                 /*
504                  * CLOSED TICKET
505                  */
506                 Fragment controls = new Fragment("controls", "closedControlsFragment", this);
507
508                 String editHref = urlFor(EditTicketPage.class, params).toString();
509                 controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));
510
511                 add(controls);
512             }
5e3521 513         } else {
JM 514             add(new Label("controls").setVisible(false));
515         }
516
517
518         /*
519          * TICKET METADATA
520          */
521         add(new Label("ticketType", ticket.type.toString()));
4a2fb1 522
f9c78c 523         add(new Label("priority", ticket.priority.toString()));
PM 524         add(new Label("severity", ticket.severity.toString()));
4a2fb1 525
5e3521 526         if (StringUtils.isEmpty(ticket.topic)) {
JM 527             add(new Label("ticketTopic").setVisible(false));
528         } else {
529             // process the topic using the bugtraq config to link things
d07b16 530             String topic = bugtraqProcessor().processText(getRepository(), repositoryName, ticket.topic);
a59627 531             String safeTopic = app().xssFilter().relaxed(topic);
JM 532             add(new Label("ticketTopic", safeTopic).setEscapeModelStrings(false));
5e3521 533         }
4a2fb1 534
JM 535
5e3521 536
JM 537
538         /*
539          * VOTERS
540          */
541         List<String> voters = ticket.getVoters();
542         Label votersCount = new Label("votes", "" + voters.size());
543         if (voters.size() == 0) {
544             WicketUtils.setCssClass(votersCount, "badge");
545         } else {
546             WicketUtils.setCssClass(votersCount, "badge badge-info");
547         }
548         add(votersCount);
5fcd95 549         if (user.isAuthenticated && app().tickets().isAcceptingTicketUpdates(repository)) {
5e3521 550             Model<String> model;
JM 551             if (ticket.isVoter(user.username)) {
552                 model = Model.of(getString("gb.removeVote"));
553             } else {
554                 model = Model.of(MessageFormat.format(getString("gb.vote"), ticket.type.toString()));
555             }
556             SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("voteLink", model) {
557
558                 private static final long serialVersionUID = 1L;
559
560                 @Override
561                 public void onClick(AjaxRequestTarget target) {
562                     Change change = new Change(user.username);
563                     if (ticket.isVoter(user.username)) {
564                         change.unvote(user.username);
565                     } else {
566                         change.vote(user.username);
567                     }
568                     app().tickets().updateTicket(repository, ticket.number, change);
47a544 569                     redirectTo(TicketsPage.class, getPageParameters());
5e3521 570                 }
JM 571             };
572             add(link);
573         } else {
574             add(new Label("voteLink").setVisible(false));
575         }
576
577
578         /*
579          * WATCHERS
580          */
581         List<String> watchers = ticket.getWatchers();
582         Label watchersCount = new Label("watchers", "" + watchers.size());
583         if (watchers.size() == 0) {
584             WicketUtils.setCssClass(watchersCount, "badge");
585         } else {
586             WicketUtils.setCssClass(watchersCount, "badge badge-info");
587         }
588         add(watchersCount);
5fcd95 589         if (user.isAuthenticated && app().tickets().isAcceptingTicketUpdates(repository)) {
5e3521 590             Model<String> model;
JM 591             if (ticket.isWatching(user.username)) {
592                 model = Model.of(getString("gb.stopWatching"));
593             } else {
594                 model = Model.of(MessageFormat.format(getString("gb.watch"), ticket.type.toString()));
595             }
596             SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("watchLink", model) {
597
598                 private static final long serialVersionUID = 1L;
599
600                 @Override
601                 public void onClick(AjaxRequestTarget target) {
602                     Change change = new Change(user.username);
603                     if (ticket.isWatching(user.username)) {
604                         change.unwatch(user.username);
605                     } else {
606                         change.watch(user.username);
607                     }
608                     app().tickets().updateTicket(repository, ticket.number, change);
47a544 609                     redirectTo(TicketsPage.class, getPageParameters());
5e3521 610                 }
JM 611             };
612             add(link);
613         } else {
614             add(new Label("watchLink").setVisible(false));
615         }
616
617
618         /*
619          * TOPIC & LABELS (DISCUSSION TAB->SIDE BAR)
620          */
621         ListDataProvider<String> labelsDp = new ListDataProvider<String>(ticket.getLabels());
622         DataView<String> labelsView = new DataView<String>("labels", labelsDp) {
623             private static final long serialVersionUID = 1L;
624
625             @Override
626             public void populateItem(final Item<String> item) {
627                 final String value = item.getModelObject();
628                 Label label = new Label("label", value);
629                 TicketLabel tLabel = app().tickets().getLabel(repository, value);
630                 String background = MessageFormat.format("background-color:{0};", tLabel.color);
631                 label.add(new SimpleAttributeModifier("style", background));
632                 item.add(label);
633             }
634         };
635
636         add(labelsView);
637
638
639         /*
640          * COMMENTS & STATUS CHANGES (DISCUSSION TAB)
641          */
642         if (comments.size() == 0) {
643             add(new Label("discussion").setVisible(false));
644         } else {
645             Fragment discussionFragment = new Fragment("discussion", "discussionFragment", this);
646             ListDataProvider<Change> discussionDp = new ListDataProvider<Change>(discussion);
647             DataView<Change> discussionView = new DataView<Change>("discussion", discussionDp) {
648                 private static final long serialVersionUID = 1L;
649
650                 @Override
651                 public void populateItem(final Item<Change> item) {
652                     final Change entry = item.getModelObject();
653                     if (entry.isMerge()) {
654                         /*
655                          * MERGE
656                          */
657                         String resolvedBy = entry.getString(Field.mergeSha);
658
659                         // identify the merged patch, it is likely the last
660                         Patchset mergedPatch = null;
661                         for (Change c : revisions) {
662                             if (c.patchset.tip.equals(resolvedBy)) {
663                                 mergedPatch = c.patchset;
664                                 break;
665                             }
666                         }
667
668                         String commitLink;
669                         if (mergedPatch == null) {
670                             // shouldn't happen, but just-in-case
671                             int len = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
672                             commitLink = resolvedBy.substring(0, len);
673                         } else {
674                             // expected result
675                             commitLink = mergedPatch.toString();
676                         }
677
678                         Fragment mergeFragment = new Fragment("entry", "mergeFragment", this);
679                         mergeFragment.add(new LinkPanel("commitLink", null, commitLink,
680                                 CommitPage.class, WicketUtils.newObjectParameter(repositoryName, resolvedBy)));
681                         mergeFragment.add(new Label("toBranch", MessageFormat.format(getString("gb.toBranch"),
682                                 "<b>" + ticket.mergeTo + "</b>")).setEscapeModelStrings(false));
683                         addUserAttributions(mergeFragment, entry, 0);
684                         addDateAttributions(mergeFragment, entry);
685
686                         item.add(mergeFragment);
687                     } else if (entry.isStatusChange()) {
688                         /*
689                          *  STATUS CHANGE
690                          */
691                         Fragment frag = new Fragment("entry", "statusFragment", this);
692                         Label status = new Label("statusChange", entry.getStatus().toString());
fdd82f 693                         String css = TicketsUI.getLozengeClass(entry.getStatus(), false);
5e3521 694                         WicketUtils.setCssClass(status, css);
JM 695                         frag.add(status);
696                         addUserAttributions(frag, entry, avatarWidth);
697                         addDateAttributions(frag, entry);
698                         item.add(frag);
699                     } else {
700                         /*
701                          * COMMENT
702                          */
d07b16 703                         String bugtraq = bugtraqProcessor().processText(getRepository(), repositoryName, entry.comment.text);
887300 704                         String comment = MarkdownUtils.transformGFM(app().settings(), bugtraq, repositoryName);
a59627 705                         String safeComment = app().xssFilter().relaxed(comment);
5e3521 706                         Fragment frag = new Fragment("entry", "commentFragment", this);
JM 707                         Label commentIcon = new Label("commentIcon");
708                         if (entry.comment.src == CommentSource.Email) {
709                             WicketUtils.setCssClass(commentIcon, "iconic-mail");
710                         } else {
711                             WicketUtils.setCssClass(commentIcon, "iconic-comment-alt2-stroke");
712                         }
713                         frag.add(commentIcon);
a59627 714                         frag.add(new Label("comment", safeComment).setEscapeModelStrings(false));
5e3521 715                         addUserAttributions(frag, entry, avatarWidth);
JM 716                         addDateAttributions(frag, entry);
717                         item.add(frag);
718                     }
719                 }
720             };
721             discussionFragment.add(discussionView);
722             add(discussionFragment);
723         }
724
725         /*
726          * ADD COMMENT PANEL
727          */
728         if (UserModel.ANONYMOUS.equals(user)
729                 || !repository.isBare
730                 || repository.isFrozen
731                 || repository.isMirror) {
732
733             // prohibit comments for anonymous users, local working copy repos,
734             // frozen repos, and mirrors
735             add(new Label("newComment").setVisible(false));
736         } else {
737             // permit user to comment
738             Fragment newComment = new Fragment("newComment", "newCommentFragment", this);
b57b9e 739             AvatarImage img = new AvatarImage("newCommentAvatar", user.username, user.emailAddress,
5e3521 740                     "gravatar-round", avatarWidth, true);
JM 741             newComment.add(img);
742             CommentPanel commentPanel = new CommentPanel("commentPanel", user, ticket, null, TicketsPage.class);
743             commentPanel.setRepository(repositoryName);
744             newComment.add(commentPanel);
745             add(newComment);
746         }
747
748
749         /*
750          *  PATCHSET TAB
751          */
752         if (currentPatchset == null) {
31e1bf 753             // no patchset available
d85bfb 754             RepositoryUrl repoUrl = getRepositoryUrl(user, repository);
1590fd 755             boolean canPropose = repoUrl != null && repoUrl.hasPermission() && repoUrl.permission.atLeast(AccessPermission.CLONE) && !UserModel.ANONYMOUS.equals(user);
d85bfb 756             if (ticket.isOpen() && app().tickets().isAcceptingNewPatchsets(repository) && canPropose) {
31e1bf 757                 // ticket & repo will accept a proposal patchset
JM 758                 // show the instructions for proposing a patchset
759                 Fragment changeIdFrag = new Fragment("patchset", "proposeFragment", this);
760                 changeIdFrag.add(new Label("proposeInstructions", MarkdownUtils.transformMarkdown(getString("gb.proposeInstructions"))).setEscapeModelStrings(false));
761                 changeIdFrag.add(new Label("ptWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Barnum")));
d85bfb 762                 changeIdFrag.add(new Label("ptWorkflowSteps", getProposeWorkflow("propose_pt.md", repoUrl.url, ticket.number)).setEscapeModelStrings(false));
31e1bf 763                 changeIdFrag.add(new Label("gitWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Git")));
d85bfb 764                 changeIdFrag.add(new Label("gitWorkflowSteps", getProposeWorkflow("propose_git.md", repoUrl.url, ticket.number)).setEscapeModelStrings(false));
31e1bf 765                 add(changeIdFrag);
JM 766             } else {
4affd0 767                 // explain why you can't propose a patchset
JM 768                 Fragment fragment = new Fragment("patchset", "canNotProposeFragment", this);
769                 String reason = "";
770                 if (ticket.isClosed()) {
771                     reason = getString("gb.ticketIsClosed");
772                 } else if (repository.isMirror) {
773                     reason = getString("gb.repositoryIsMirror");
774                 } else if (repository.isFrozen) {
775                     reason = getString("gb.repositoryIsFrozen");
776                 } else if (!repository.acceptNewPatchsets) {
777                     reason = getString("gb.repositoryDoesNotAcceptPatchsets");
d85bfb 778                 } else if (!canPropose) {
JM 779                     if (UserModel.ANONYMOUS.equals(user)) {
780                         reason = getString("gb.anonymousCanNotPropose");
781                     } else {
782                         reason = getString("gb.youDoNotHaveClonePermission");
783                     }
4affd0 784                 } else {
JM 785                     reason = getString("gb.serverDoesNotAcceptPatchsets");
786                 }
787                 fragment.add(new Label("reason", reason));
788                 add(fragment);
31e1bf 789             }
5e3521 790         } else {
JM 791             // show current patchset
792             Fragment patchsetFrag = new Fragment("patchset", "patchsetFragment", this);
793             patchsetFrag.add(new Label("commitsInPatchset", MessageFormat.format(getString("gb.commitsInPatchsetN"), currentPatchset.number)));
794
a0d5c8 795             patchsetFrag.add(createMergePanel(user, repository));
JM 796
797             if (ticket.isOpen()) {
798                 // current revision
799                 MarkupContainer panel = createPatchsetPanel("panel", repository, user);
800                 patchsetFrag.add(panel);
801                 addUserAttributions(patchsetFrag, currentRevision, avatarWidth);
802                 addUserAttributions(panel, currentRevision, 0);
803                 addDateAttributions(panel, currentRevision);
804             } else {
805                 // current revision
806                 patchsetFrag.add(new Label("panel").setVisible(false));
807             }
5e3521 808
JM 809             // commits
810             List<RevCommit> commits = JGitUtils.getRevLog(getRepository(), currentPatchset.base, currentPatchset.tip);
811             ListDataProvider<RevCommit> commitsDp = new ListDataProvider<RevCommit>(commits);
812             DataView<RevCommit> commitsView = new DataView<RevCommit>("commit", commitsDp) {
813                 private static final long serialVersionUID = 1L;
814
815                 @Override
816                 public void populateItem(final Item<RevCommit> item) {
817                     RevCommit commit = item.getModelObject();
818                     PersonIdent author = commit.getAuthorIdent();
b57b9e 819                     item.add(new AvatarImage("authorAvatar", author.getName(), author.getEmailAddress(), null, 16, false));
5e3521 820                     item.add(new Label("author", commit.getAuthorIdent().getName()));
JM 821                     item.add(new LinkPanel("commitId", null, getShortObjectId(commit.getName()),
822                             CommitPage.class, WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
823                     item.add(new LinkPanel("diff", "link", getString("gb.diff"), CommitDiffPage.class,
824                             WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
825                     item.add(new Label("title", StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG_REFS)));
a59232 826                     item.add(WicketUtils.createDateLabel("commitDate", JGitUtils.getAuthorDate(commit), GitBlitWebSession
5e3521 827                             .get().getTimezone(), getTimeUtils(), false));
JM 828                     item.add(new DiffStatPanel("commitDiffStat", 0, 0, true));
829                 }
830             };
831             patchsetFrag.add(commitsView);
832             add(patchsetFrag);
833         }
834
835
836         /*
837          * ACTIVITY TAB
838          */
839         Fragment revisionHistory = new Fragment("activity", "activityFragment", this);
840         List<Change> events = new ArrayList<Change>(ticket.changes);
841         Collections.sort(events);
842         Collections.reverse(events);
843         ListDataProvider<Change> eventsDp = new ListDataProvider<Change>(events);
844         DataView<Change> eventsView = new DataView<Change>("event", eventsDp) {
845             private static final long serialVersionUID = 1L;
846
847             @Override
848             public void populateItem(final Item<Change> item) {
849                 Change event = item.getModelObject();
850
851                 addUserAttributions(item, event, 16);
852
853                 if (event.hasPatchset()) {
854                     // patchset
855                     Patchset patchset = event.patchset;
856                     String what;
857                     if (event.isStatusChange() && (Status.New == event.getStatus())) {
858                         what = getString("gb.proposedThisChange");
859                     } else if (patchset.rev == 1) {
860                         what = MessageFormat.format(getString("gb.uploadedPatchsetN"), patchset.number);
861                     } else {
862                         if (patchset.added == 1) {
863                             what = getString("gb.addedOneCommit");
864                         } else {
865                             what = MessageFormat.format(getString("gb.addedNCommits"), patchset.added);
866                         }
867                     }
868                     item.add(new Label("what", what));
869
870                     LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev,
871                             ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true);
872                     WicketUtils.setHtmlTooltip(psr, patchset.toString());
873                     item.add(psr);
874                     String typeCss = getPatchsetTypeCss(patchset.type);
875                     Label typeLabel = new Label("patchsetType", patchset.type.toString());
876                     if (typeCss == null) {
877                         typeLabel.setVisible(false);
878                     } else {
879                         WicketUtils.setCssClass(typeLabel, typeCss);
880                     }
881                     item.add(typeLabel);
882
883                     // show commit diffstat
884                     item.add(new DiffStatPanel("patchsetDiffStat", patchset.insertions, patchset.deletions, patchset.rev > 1));
885                 } else if (event.hasComment()) {
886                     // comment
887                     item.add(new Label("what", getString("gb.commented")));
888                     item.add(new Label("patchsetRevision").setVisible(false));
889                     item.add(new Label("patchsetType").setVisible(false));
890                     item.add(new Label("patchsetDiffStat").setVisible(false));
891                 } else if (event.hasReview()) {
892                     // review
893                     String score;
894                     switch (event.review.score) {
895                     case approved:
896                         score = "<span style='color:darkGreen'>" + getScoreDescription(event.review.score) + "</span>";
897                         break;
898                     case vetoed:
899                         score = "<span style='color:darkRed'>" + getScoreDescription(event.review.score) + "</span>";
900                         break;
901                     default:
902                         score = getScoreDescription(event.review.score);
903                     }
904                     item.add(new Label("what", MessageFormat.format(getString("gb.reviewedPatchsetRev"),
905                             event.review.patchset, event.review.rev, score))
906                             .setEscapeModelStrings(false));
907                     item.add(new Label("patchsetRevision").setVisible(false));
908                     item.add(new Label("patchsetType").setVisible(false));
909                     item.add(new Label("patchsetDiffStat").setVisible(false));
910                 } else {
911                     // field change
912                     item.add(new Label("patchsetRevision").setVisible(false));
913                     item.add(new Label("patchsetType").setVisible(false));
914                     item.add(new Label("patchsetDiffStat").setVisible(false));
915
916                     String what = "";
917                     if (event.isStatusChange()) {
918                     switch (event.getStatus()) {
919                     case New:
920                         if (ticket.isProposal()) {
921                             what = getString("gb.proposedThisChange");
922                         } else {
923                             what = getString("gb.createdThisTicket");
924                         }
925                         break;
926                     default:
927                         break;
928                     }
929                     }
930                     item.add(new Label("what", what).setVisible(what.length() > 0));
931                 }
932
933                 addDateAttributions(item, event);
934
935                 if (event.hasFieldChanges()) {
936                     StringBuilder sb = new StringBuilder();
937                     sb.append("<table class=\"summary\"><tbody>");
938                     for (Map.Entry<Field, String> entry : event.fields.entrySet()) {
939                         String value;
940                         switch (entry.getKey()) {
941                             case body:
942                                 String body = entry.getValue();
943                                 if (event.isStatusChange() && Status.New == event.getStatus() && StringUtils.isEmpty(body)) {
944                                     // ignore initial empty description
945                                     continue;
946                                 }
947                                 // trim body changes
948                                 if (StringUtils.isEmpty(body)) {
949                                     value = "<i>" + ESC_NIL + "</i>";
950                                 } else {
951                                     value = StringUtils.trimString(body, Constants.LEN_SHORTLOG_REFS);
952                                 }
953                                 break;
954                             case status:
955                                 // special handling for status
956                                 Status status = event.getStatus();
fdd82f 957                                 String css = TicketsUI.getLozengeClass(status, true);
5e3521 958                                 value = String.format("<span class=\"%1$s\">%2$s</span>", css, status.toString());
JM 959                                 break;
960                             default:
961                                 value = StringUtils.isEmpty(entry.getValue()) ? ("<i>" + ESC_NIL + "</i>") : StringUtils.escapeForHtml(entry.getValue(), false);
962                                 break;
963                         }
964                         sb.append("<tr><th style=\"width:70px;\">");
3d1b42 965                         try {
JM 966                             sb.append(getString("gb." + entry.getKey().name()));
967                         } catch (Exception e) {
968                             sb.append(entry.getKey().name());
969                         }
5e3521 970                         sb.append("</th><td>");
JM 971                         sb.append(value);
972                         sb.append("</td></tr>");
973                     }
974                     sb.append("</tbody></table>");
a59627 975                     String safeHtml = app().xssFilter().relaxed(sb.toString());
JM 976                     item.add(new Label("fields", safeHtml).setEscapeModelStrings(false));
5e3521 977                 } else {
JM 978                     item.add(new Label("fields").setVisible(false));
979                 }
980             }
981         };
982         revisionHistory.add(eventsView);
983         add(revisionHistory);
984     }
985
986     protected void addUserAttributions(MarkupContainer container, Change entry, int avatarSize) {
987         UserModel commenter = app().users().getUserModel(entry.author);
988         if (commenter == null) {
989             // unknown user
b57b9e 990             container.add(new AvatarImage("changeAvatar", entry.author,
5e3521 991                     entry.author, null, avatarSize, false).setVisible(avatarSize > 0));
JM 992             container.add(new Label("changeAuthor", entry.author.toLowerCase()));
993         } else {
994             // known user
b57b9e 995             container.add(new AvatarImage("changeAvatar", commenter.getDisplayName(),
5e3521 996                     commenter.emailAddress, avatarSize > 24 ? "gravatar-round" : null,
JM 997                             avatarSize, true).setVisible(avatarSize > 0));
998             container.add(new LinkPanel("changeAuthor", null, commenter.getDisplayName(),
999                     UserPage.class, WicketUtils.newUsernameParameter(commenter.username)));
1000         }
1001     }
1002
1003     protected void addDateAttributions(MarkupContainer container, Change entry) {
1004         container.add(WicketUtils.createDateLabel("changeDate", entry.date, GitBlitWebSession
1005                 .get().getTimezone(), getTimeUtils(), false));
1006
1007         // set the id attribute
1008         if (entry.hasComment()) {
1009             container.setOutputMarkupId(true);
1010             container.add(new AttributeModifier("id", Model.of(entry.getId())));
1011             ExternalLink link = new ExternalLink("changeLink", "#" + entry.getId());
1012             container.add(link);
1013         } else {
1014             container.add(new Label("changeLink").setVisible(false));
1015         }
1016     }
1017
1018     protected String getProposeWorkflow(String resource, String url, long ticketId) {
1019         String md = readResource(resource);
1020         md = md.replace("${url}", url);
1021         md = md.replace("${repo}", StringUtils.getLastPathElement(StringUtils.stripDotGit(repositoryName)));
1022         md = md.replace("${ticketId}", "" + ticketId);
1023         md = md.replace("${patchset}", "" + 1);
1024         md = md.replace("${reviewBranch}", Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticketId)));
f1b882 1025         String integrationBranch = Repository.shortenRefName(getRepositoryModel().mergeTo);
b335dd 1026         if (!StringUtils.isEmpty(ticket.mergeTo)) {
JM 1027             integrationBranch = ticket.mergeTo;
1028         }
1029         md = md.replace("${integrationBranch}", integrationBranch);
5e3521 1030         return MarkdownUtils.transformMarkdown(md);
JM 1031     }
1032
1033     protected Fragment createPatchsetPanel(String wicketId, RepositoryModel repository, UserModel user) {
1034         final Patchset currentPatchset = ticket.getCurrentPatchset();
1035         List<Patchset> patchsets = new ArrayList<Patchset>(ticket.getPatchsetRevisions(currentPatchset.number));
1036         patchsets.remove(currentPatchset);
1037         Collections.reverse(patchsets);
1038
1039         Fragment panel = new Fragment(wicketId, "collapsiblePatchsetFragment", this);
1040
1041         // patchset header
1042         String ps = "<b>" + currentPatchset.number + "</b>";
1043         if (currentPatchset.rev == 1) {
1044             panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetN"), ps)).setEscapeModelStrings(false));
1045         } else {
1046             String rev = "<b>" + currentPatchset.rev + "</b>";
1047             panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetNRevisionN"), ps, rev)).setEscapeModelStrings(false));
1048         }
1049         panel.add(new LinkPanel("patchId", null, "rev " + currentPatchset.rev,
1050                 CommitPage.class, WicketUtils.newObjectParameter(repositoryName, currentPatchset.tip), true));
1051
1052         // compare menu
1053         panel.add(new LinkPanel("compareMergeBase", null, getString("gb.compareToMergeBase"),
1054                 ComparePage.class, WicketUtils.newRangeParameter(repositoryName, currentPatchset.base, currentPatchset.tip), true));
1055
1056         ListDataProvider<Patchset> compareMenuDp = new ListDataProvider<Patchset>(patchsets);
1057         DataView<Patchset> compareMenu = new DataView<Patchset>("comparePatch", compareMenuDp) {
1058             private static final long serialVersionUID = 1L;
1059             @Override
1060             public void populateItem(final Item<Patchset> item) {
1061                 Patchset patchset = item.getModelObject();
1062                 LinkPanel link = new LinkPanel("compareLink", null,
1063                         MessageFormat.format(getString("gb.compareToN"), patchset.number + "-" + patchset.rev),
1064                         ComparePage.class, WicketUtils.newRangeParameter(getRepositoryModel().name,
1065                                 patchset.tip, currentPatchset.tip), true);
1066                 item.add(link);
1067
1068             }
1069         };
1070         panel.add(compareMenu);
1071
1072
1073         // reviews
1074         List<Change> reviews = ticket.getReviews(currentPatchset);
1075         ListDataProvider<Change> reviewsDp = new ListDataProvider<Change>(reviews);
1076         DataView<Change> reviewsView = new DataView<Change>("reviews", reviewsDp) {
1077             private static final long serialVersionUID = 1L;
1078
1079             @Override
1080             public void populateItem(final Item<Change> item) {
1081                 Change change = item.getModelObject();
1082                 final String username = change.author;
1083                 UserModel user = app().users().getUserModel(username);
1084                 if (user == null) {
1085                     item.add(new Label("reviewer", username));
1086                 } else {
1087                     item.add(new LinkPanel("reviewer", null, user.getDisplayName(),
1088                             UserPage.class, WicketUtils.newUsernameParameter(username)));
1089                 }
1090
1091                 // indicate review score
1092                 Review review = change.review;
1093                 Label scoreLabel = new Label("score");
1094                 String scoreClass = getScoreClass(review.score);
1095                 String tooltip = getScoreDescription(review.score);
1096                 WicketUtils.setCssClass(scoreLabel, scoreClass);
1097                 if (!StringUtils.isEmpty(tooltip)) {
1098                     WicketUtils.setHtmlTooltip(scoreLabel, tooltip);
1099                 }
1100                 item.add(scoreLabel);
1101             }
1102         };
1103         panel.add(reviewsView);
1104
1105
57a71d 1106         if (ticket.isOpen() && user.canReviewPatchset(repository) && app().tickets().isAcceptingTicketUpdates(repository)) {
5e3521 1107             // can only review open tickets
JM 1108             Review myReview = null;
1109             for (Change change : ticket.getReviews(currentPatchset)) {
1110                 if (change.author.equals(user.username)) {
1111                     myReview = change.review;
1112                 }
1113             }
1114
1115             // user can review, add review controls
1116             Fragment reviewControls = new Fragment("reviewControls", "reviewControlsFragment", this);
1117
1118             // show "approve" button if no review OR not current score
1119             if (user.canApprovePatchset(repository) && (myReview == null || Score.approved != myReview.score)) {
1120                 reviewControls.add(createReviewLink("approveLink", Score.approved));
1121             } else {
1122                 reviewControls.add(new Label("approveLink").setVisible(false));
1123             }
1124
1125             // show "looks good" button if no review OR not current score
1126             if (myReview == null || Score.looks_good != myReview.score) {
1127                 reviewControls.add(createReviewLink("looksGoodLink", Score.looks_good));
1128             } else {
1129                 reviewControls.add(new Label("looksGoodLink").setVisible(false));
1130             }
1131
1132             // show "needs improvement" button if no review OR not current score
1133             if (myReview == null || Score.needs_improvement != myReview.score) {
1134                 reviewControls.add(createReviewLink("needsImprovementLink", Score.needs_improvement));
1135             } else {
1136                 reviewControls.add(new Label("needsImprovementLink").setVisible(false));
1137             }
1138
1139             // show "veto" button if no review OR not current score
1140             if (user.canVetoPatchset(repository) && (myReview == null || Score.vetoed != myReview.score)) {
1141                 reviewControls.add(createReviewLink("vetoLink", Score.vetoed));
1142             } else {
1143                 reviewControls.add(new Label("vetoLink").setVisible(false));
1144             }
1145             panel.add(reviewControls);
1146         } else {
1147             // user can not review
1148             panel.add(new Label("reviewControls").setVisible(false));
1149         }
1150
1151         String insertions = MessageFormat.format("<span style=\"color:darkGreen;font-weight:bold;\">+{0}</span>", ticket.insertions);
1152         String deletions = MessageFormat.format("<span style=\"color:darkRed;font-weight:bold;\">-{0}</span>", ticket.deletions);
1153         panel.add(new Label("patchsetStat", MessageFormat.format(StringUtils.escapeForHtml(getString("gb.diffStat"), false),
1154                 insertions, deletions)).setEscapeModelStrings(false));
1155
1156         // changed paths list
1157         List<PathChangeModel> paths = JGitUtils.getFilesInRange(getRepository(), currentPatchset.base, currentPatchset.tip);
1158         ListDataProvider<PathChangeModel> pathsDp = new ListDataProvider<PathChangeModel>(paths);
1159         DataView<PathChangeModel> pathsView = new DataView<PathChangeModel>("changedPath", pathsDp) {
1160             private static final long serialVersionUID = 1L;
1161             int counter;
1162
1163             @Override
1164             public void populateItem(final Item<PathChangeModel> item) {
1165                 final PathChangeModel entry = item.getModelObject();
1166                 Label changeType = new Label("changeType", "");
1167                 WicketUtils.setChangeTypeCssClass(changeType, entry.changeType);
1168                 setChangeTypeTooltip(changeType, entry.changeType);
1169                 item.add(changeType);
1170
1171                 boolean hasSubmodule = false;
1172                 String submodulePath = null;
1173                 if (entry.isTree()) {
1174                     // tree
1175                     item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,
1176                             WicketUtils
1177                                     .newPathParameter(repositoryName, currentPatchset.tip, entry.path), true));
1178                     item.add(new Label("diffStat").setVisible(false));
1179                 } else if (entry.isSubmodule()) {
1180                     // submodule
1181                     String submoduleId = entry.objectId;
1182                     SubmoduleModel submodule = getSubmodule(entry.path);
1183                     submodulePath = submodule.gitblitPath;
1184                     hasSubmodule = submodule.hasSubmodule;
1185
1186                     item.add(new LinkPanel("pathName", "list", entry.path + " @ " +
1187                             getShortObjectId(submoduleId), TreePage.class,
1188                             WicketUtils.newPathParameter(submodulePath, submoduleId, ""), true).setEnabled(hasSubmodule));
1189                     item.add(new Label("diffStat").setVisible(false));
1190                 } else {
1191                     // blob
1192                     String displayPath = entry.path;
1193                     String path = entry.path;
1194                     if (entry.isSymlink()) {
e562d4 1195                         RevCommit commit = JGitUtils.getCommit(getRepository(), PatchsetCommand.getTicketBranch(ticket.number));
5e3521 1196                         path = JGitUtils.getStringContent(getRepository(), commit.getTree(), path);
JM 1197                         displayPath = entry.path + " -> " + path;
1198                     }
1199
1200                     if (entry.changeType.equals(ChangeType.ADD)) {
1201                         // add show view
1202                         item.add(new LinkPanel("pathName", "list", displayPath, BlobPage.class,
1203                                 WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
1204                     } else if (entry.changeType.equals(ChangeType.DELETE)) {
1205                         // delete, show label
1206                         item.add(new Label("pathName", displayPath));
1207                     } else {
1208                         // mod, show diff
1209                         item.add(new LinkPanel("pathName", "list", displayPath, BlobDiffPage.class,
1210                                 WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
1211                     }
bbd820 1212                     item.add(new DiffStatPanel("diffStat", entry.insertions, entry.deletions, true));
5e3521 1213                 }
JM 1214
1215                 // quick links
1216                 if (entry.isSubmodule()) {
1217                     // submodule
1218                     item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
1219                             .newPathParameter(repositoryName, entry.commitId, entry.path)))
1220                             .setEnabled(!entry.changeType.equals(ChangeType.ADD)));
1221                     item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils
1222                             .newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));
1223                 } else {
1224                     // tree or blob
1225                     item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
1226                             .newBlobDiffParameter(repositoryName, currentPatchset.base, currentPatchset.tip, entry.path)))
1227                             .setEnabled(!entry.changeType.equals(ChangeType.ADD)
1228                                     && !entry.changeType.equals(ChangeType.DELETE)));
1229                     item.add(setNewTarget(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
1230                             .newPathParameter(repositoryName, currentPatchset.tip, entry.path)))
1231                             .setEnabled(!entry.changeType.equals(ChangeType.DELETE)));
1232                 }
1233
1234                 WicketUtils.setAlternatingBackground(item, counter);
1235                 counter++;
1236             }
1237         };
1238         panel.add(pathsView);
1239
1221a4 1240         addPtCheckoutInstructions(user, repository, panel);
JM 1241         addGitCheckoutInstructions(user, repository, panel);
5e3521 1242
JM 1243         return panel;
1244     }
1245
1246     protected IconAjaxLink<String> createReviewLink(String wicketId, final Score score) {
1247         return new IconAjaxLink<String>(wicketId, getScoreClass(score), Model.of(getScoreDescription(score))) {
1248
1249             private static final long serialVersionUID = 1L;
1250
1251             @Override
1252             public void onClick(AjaxRequestTarget target) {
1253                 review(score);
1254             }
1255         };
1256     }
1257
1258     protected String getScoreClass(Score score) {
1259         switch (score) {
1260         case vetoed:
1261             return "fa fa-exclamation-circle";
1262         case needs_improvement:
1263             return "fa fa-thumbs-o-down";
1264         case looks_good:
1265             return "fa fa-thumbs-o-up";
1266         case approved:
1267             return "fa fa-check-circle";
1268         case not_reviewed:
1269         default:
1270             return "fa fa-minus-circle";
1271         }
1272     }
1273
1274     protected String getScoreDescription(Score score) {
1275         String description;
1276         switch (score) {
1277         case vetoed:
1278             description = getString("gb.veto");
1279             break;
1280         case needs_improvement:
1281             description = getString("gb.needsImprovement");
1282             break;
1283         case looks_good:
1284             description = getString("gb.looksGood");
1285             break;
1286         case approved:
1287             description = getString("gb.approve");
1288             break;
1289         case not_reviewed:
1290         default:
1291             description = getString("gb.hasNotReviewed");
1292         }
1293         return String.format("%1$s (%2$+d)", description, score.getValue());
1294     }
1295
1296     protected void review(Score score) {
1297         UserModel user = GitBlitWebSession.get().getUser();
1298         Patchset ps = ticket.getCurrentPatchset();
1299         Change change = new Change(user.username);
1300         change.review(ps, score, !ticket.isReviewer(user.username));
1301         if (!ticket.isWatching(user.username)) {
1302             change.watch(user.username);
1303         }
1304         TicketModel updatedTicket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);
1305         app().tickets().createNotifier().sendMailing(updatedTicket);
47a544 1306         redirectTo(TicketsPage.class, getPageParameters());
5e3521 1307     }
JM 1308
1309     protected <X extends MarkupContainer> X setNewTarget(X x) {
1310         x.add(new SimpleAttributeModifier("target", "_blank"));
1311         return x;
1312     }
1313
1221a4 1314     protected void addGitCheckoutInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
5e3521 1315         panel.add(new Label("gitStep1", MessageFormat.format(getString("gb.stepN"), 1)));
JM 1316         panel.add(new Label("gitStep2", MessageFormat.format(getString("gb.stepN"), 2)));
1317
1318         String ticketBranch  = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
1319
1221a4 1320         String step1 = "git fetch origin";
2f8b4f 1321         String step2 = MessageFormat.format("git checkout {0} && git pull --ff-only\nOR\ngit checkout {0} && git reset --hard origin/{0}", ticketBranch);
5e3521 1322
JM 1323         panel.add(new Label("gitPreStep1", step1));
1324         panel.add(new Label("gitPreStep2", step2));
1325
1326         panel.add(createCopyFragment("gitCopyStep1", step1.replace("\n", " && ")));
1327         panel.add(createCopyFragment("gitCopyStep2", step2.replace("\n", " && ")));
1328     }
1329
1221a4 1330     protected void addPtCheckoutInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
5e3521 1331         String step1 = MessageFormat.format("pt checkout {0,number,0}", ticket.number);
JM 1332         panel.add(new Label("ptPreStep", step1));
1333         panel.add(createCopyFragment("ptCopyStep", step1));
1334     }
1335
1336     /**
1337      * Adds a merge panel for the patchset to the markup container.  The panel
1338      * may just a message if the patchset can not be merged.
1339      *
1340      * @param c
1341      * @param user
1342      * @param repository
1343      */
1344     protected Component createMergePanel(UserModel user, RepositoryModel repository) {
1345         Patchset patchset = ticket.getCurrentPatchset();
1346         if (patchset == null) {
1347             // no patchset to merge
1348             return new Label("mergePanel");
1349         }
1350
1351         boolean allowMerge;
1352         if (repository.requireApproval) {
1353             // rpeository requires approval
1354             allowMerge = ticket.isOpen() && ticket.isApproved(patchset);
1355         } else {
1356             // vetos are binding
1357             allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset);
1358         }
1359
1360         MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo);
1361         if (allowMerge) {
1362             if (MergeStatus.MERGEABLE == mergeStatus) {
1363                 // patchset can be cleanly merged to integration branch OR has already been merged
1364                 Fragment mergePanel = new Fragment("mergePanel", "mergeableFragment", this);
1365                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetMergeable"), ticket.mergeTo)));
1366                 if (user.canPush(repository)) {
1367                     // user can merge locally
1368                     SimpleAjaxLink<String> mergeButton = new SimpleAjaxLink<String>("mergeButton", Model.of(getString("gb.merge"))) {
1369
1370                         private static final long serialVersionUID = 1L;
1371
1372                         @Override
1373                         public void onClick(AjaxRequestTarget target) {
1374
1375                             // ensure the patchset is still current AND not vetoed
1376                             Patchset patchset = ticket.getCurrentPatchset();
1377                             final TicketModel refreshedTicket = app().tickets().getTicket(getRepositoryModel(), ticket.number);
1378                             if (patchset.equals(refreshedTicket.getCurrentPatchset())) {
1379                                 // patchset is current, check for recent veto
1380                                 if (!refreshedTicket.isVetoed(patchset)) {
1381                                     // patchset is not vetoed
1382
1383                                     // execute the merge using the ticket service
1384                                     app().tickets().exec(new Runnable() {
1385                                         @Override
1386                                         public void run() {
1387                                             PatchsetReceivePack rp = new PatchsetReceivePack(
1388                                                     app().gitblit(),
1389                                                     getRepository(),
1390                                                     getRepositoryModel(),
1391                                                     GitBlitWebSession.get().getUser());
1392                                             MergeStatus result = rp.merge(refreshedTicket);
1393                                             if (MergeStatus.MERGED == result) {
1394                                                 // notify participants and watchers
1395                                                 rp.sendAll();
1396                                             } else {
1397                                                 // merge failure
1398                                                 String msg = MessageFormat.format("Failed to merge ticket {0,number,0}: {1}", ticket.number, result.name());
1399                                                 logger.error(msg);
1400                                                 GitBlitWebSession.get().cacheErrorMessage(msg);
1401                                             }
1402                                         }
1403                                     });
1404                                 } else {
1405                                     // vetoed patchset
1406                                     String msg = MessageFormat.format("Can not merge ticket {0,number,0}, patchset {1,number,0} has been vetoed!",
1407                                             ticket.number, patchset.number);
1408                                     GitBlitWebSession.get().cacheErrorMessage(msg);
1409                                     logger.error(msg);
1410                                 }
1411                             } else {
1412                                 // not current patchset
1413                                 String msg = MessageFormat.format("Can not merge ticket {0,number,0}, the patchset has been updated!", ticket.number);
1414                                 GitBlitWebSession.get().cacheErrorMessage(msg);
1415                                 logger.error(msg);
1416                             }
47a544 1417                             
K 1418                             redirectTo(TicketsPage.class, getPageParameters());
5e3521 1419                         }
JM 1420                     };
1421                     mergePanel.add(mergeButton);
1422                     Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetMergeableMore");
1423                     mergePanel.add(instructions);
1424                 } else {
1425                     mergePanel.add(new Label("mergeButton").setVisible(false));
1426                     mergePanel.add(new Label("mergeMore").setVisible(false));
1427                 }
1428                 return mergePanel;
1429             } else if (MergeStatus.ALREADY_MERGED == mergeStatus) {
1430                 // patchset already merged
1431                 Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
1432                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
1433                 return mergePanel;
4a2fb1 1434             } else if (MergeStatus.MISSING_INTEGRATION_BRANCH == mergeStatus) {
JM 1435                 // target/integration branch is missing
1436                 Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this);
1437                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
1438                 mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.missingIntegrationBranchMore"), ticket.mergeTo)));
1439                 return mergePanel;
5e3521 1440             } else {
JM 1441                 // patchset can not be cleanly merged
1442                 Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this);
1443                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
1444                 if (user.canPush(repository)) {
1445                     // user can merge locally
1446                     Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetNotMergeableMore");
1447                     mergePanel.add(instructions);
1448                 } else {
1449                     mergePanel.add(new Label("mergeMore").setVisible(false));
1450                 }
1451                 return mergePanel;
1452             }
1453         } else {
1454             // merge not allowed
1455             if (MergeStatus.ALREADY_MERGED == mergeStatus) {
1456                 // patchset already merged
1457                 Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
1458                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
1459                 return mergePanel;
1460             } else if (ticket.isVetoed(patchset)) {
1461                 // patchset has been vetoed
1462                 Fragment mergePanel =  new Fragment("mergePanel", "vetoedFragment", this);
1463                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
1464                 return mergePanel;
1465             } else if (repository.requireApproval) {
1466                 // patchset has been not been approved for merge
1467                 Fragment mergePanel = new Fragment("mergePanel", "notApprovedFragment", this);
1468                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotApproved"), ticket.mergeTo)));
1469                 mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.patchsetNotApprovedMore"), ticket.mergeTo)));
1470                 return mergePanel;
1471             } else {
1472                 // other case
1473                 return new Label("mergePanel");
1474             }
1475         }
1476     }
1477
1478     protected Component getMergeInstructions(UserModel user, RepositoryModel repository, String markupId, String infoKey) {
1479         Fragment cmd = new Fragment(markupId, "commandlineMergeFragment", this);
1480         cmd.add(new Label("instructions", MessageFormat.format(getString(infoKey), ticket.mergeTo)));
1481
1482         // git instructions
1483         cmd.add(new Label("mergeStep1", MessageFormat.format(getString("gb.stepN"), 1)));
1484         cmd.add(new Label("mergeStep2", MessageFormat.format(getString("gb.stepN"), 2)));
1485         cmd.add(new Label("mergeStep3", MessageFormat.format(getString("gb.stepN"), 3)));
1486
1487         String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
1488         String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
1489
bf426b 1490         String step1 = MessageFormat.format("git checkout -b {0} {1}", reviewBranch, ticket.mergeTo);
JM 1491         String step2 = MessageFormat.format("git pull origin {0}", ticketBranch);
1492         String step3 = MessageFormat.format("git checkout {0}\ngit merge {1}\ngit push origin {0}\ngit branch -d {1}", ticket.mergeTo, reviewBranch);
5e3521 1493
JM 1494         cmd.add(new Label("mergePreStep1", step1));
1495         cmd.add(new Label("mergePreStep2", step2));
1496         cmd.add(new Label("mergePreStep3", step3));
1497
1498         cmd.add(createCopyFragment("mergeCopyStep1", step1.replace("\n", " && ")));
1499         cmd.add(createCopyFragment("mergeCopyStep2", step2.replace("\n", " && ")));
1500         cmd.add(createCopyFragment("mergeCopyStep3", step3.replace("\n", " && ")));
1501
1502         // pt instructions
1503         String ptStep = MessageFormat.format("pt pull {0,number,0}", ticket.number);
1504         cmd.add(new Label("ptMergeStep", ptStep));
1505         cmd.add(createCopyFragment("ptMergeCopyStep", step1.replace("\n", " && ")));
1506         return cmd;
1507     }
1508
1509     /**
1510      * Returns the primary repository url
1511      *
1512      * @param user
1513      * @param repository
1514      * @return the primary repository url
1515      */
d85bfb 1516     protected RepositoryUrl getRepositoryUrl(UserModel user, RepositoryModel repository) {
5e3521 1517         HttpServletRequest req = ((WebRequest) getRequest()).getHttpServletRequest();
7d3a31 1518         List<RepositoryUrl> urls = app().services().getRepositoryUrls(req, user, repository);
b236ce 1519         if (ArrayUtils.isEmpty(urls)) {
JM 1520             return null;
1521         }
d85bfb 1522         RepositoryUrl primary = urls.get(0);
JM 1523         return primary;
5e3521 1524     }
JM 1525
1526     /**
1527      * Returns the ticket (if any) that this commit references.
1528      *
1529      * @param commit
1530      * @return null or a ticket
1531      */
1532     protected TicketModel getTicket(RevCommit commit) {
1533         try {
1534             Map<String, Ref> refs = getRepository().getRefDatabase().getRefs(Constants.R_TICKETS_PATCHSETS);
1535             for (Map.Entry<String, Ref> entry : refs.entrySet()) {
1536                 if (entry.getValue().getObjectId().equals(commit.getId())) {
1537                     long id = PatchsetCommand.getTicketNumber(entry.getKey());
1538                     TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), id);
1539                     return ticket;
1540                 }
1541             }
1542         } catch (Exception e) {
1543             logger().error("failed to determine ticket from ref", e);
1544         }
1545         return null;
1546     }
1547
1548     protected String getPatchsetTypeCss(PatchsetType type) {
1549         String typeCss;
1550         switch (type) {
1551             case Rebase:
1552             case Rebase_Squash:
fdd82f 1553                 typeCss = TicketsUI.getLozengeClass(Status.Declined, false);
5e3521 1554                 break;
JM 1555             case Squash:
1556             case Amend:
fdd82f 1557                 typeCss = TicketsUI.getLozengeClass(Status.On_Hold, false);
5e3521 1558                 break;
JM 1559             case Proposal:
fdd82f 1560                 typeCss = TicketsUI.getLozengeClass(Status.New, false);
5e3521 1561                 break;
JM 1562             case FastForward:
1563             default:
1564                 typeCss = null;
1565             break;
1566         }
1567         return typeCss;
1568     }
1569
1570     @Override
1571     protected String getPageName() {
1572         return getString("gb.ticket");
1573     }
1574
1575     @Override
1576     protected Class<? extends BasePage> getRepoNavPageClass() {
1577         return TicketsPage.class;
1578     }
1579
1580     @Override
1581     protected String getPageTitle(String repositoryName) {
1582         return "#" + ticket.number + " - " + ticket.title;
1583     }
1584
1585     protected Fragment createCopyFragment(String wicketId, String text) {
1586         if (app().settings().getBoolean(Keys.web.allowFlashCopyToClipboard, true)) {
1587             // clippy: flash-based copy & paste
1588             Fragment copyFragment = new Fragment(wicketId, "clippyPanel", this);
1589             String baseUrl = WicketUtils.getGitblitURL(getRequest());
1590             ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf");
1591             clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(text));
1592             copyFragment.add(clippy);
1593             return copyFragment;
1594         } else {
1595             // javascript: manual copy & paste with modal browser prompt dialog
1596             Fragment copyFragment = new Fragment(wicketId, "jsPanel", this);
1597             ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");
1598             img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", text));
1599             copyFragment.add(img);
1600             return copyFragment;
1601         }
1602     }
1603 }