Merge branch 'svn_18_3'
[idea/community.git] / plugins / svn4idea / src / org / jetbrains / idea / svn / history / SvnHistoryProvider.java
1 /*
2  * Copyright 2000-2009 JetBrains s.r.o.
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 org.jetbrains.idea.svn.history;
17
18 import com.intellij.openapi.actionSystem.AnAction;
19 import com.intellij.openapi.progress.ProgressIndicator;
20 import com.intellij.openapi.progress.ProgressManager;
21 import com.intellij.openapi.util.Pair;
22 import com.intellij.openapi.util.Ref;
23 import com.intellij.openapi.util.text.StringUtil;
24 import com.intellij.openapi.vcs.FilePath;
25 import com.intellij.openapi.vcs.VcsConfiguration;
26 import com.intellij.openapi.vcs.VcsException;
27 import com.intellij.openapi.vcs.annotate.ShowAllAffectedGenericAction;
28 import com.intellij.openapi.vcs.changes.Change;
29 import com.intellij.openapi.vcs.changes.ChangeListManager;
30 import com.intellij.openapi.vcs.changes.ContentRevision;
31 import com.intellij.openapi.vcs.changes.issueLinks.TableLinkMouseListener;
32 import com.intellij.openapi.vcs.history.*;
33 import com.intellij.openapi.vfs.VirtualFile;
34 import com.intellij.ui.*;
35 import com.intellij.util.Consumer;
36 import com.intellij.util.PlatformIcons;
37 import com.intellij.util.ThrowableConsumer;
38 import com.intellij.util.ui.ColumnInfo;
39 import com.intellij.util.ui.UIUtil;
40 import org.jetbrains.annotations.NotNull;
41 import org.jetbrains.annotations.Nullable;
42 import org.jetbrains.idea.svn.*;
43 import org.tmatesoft.svn.core.*;
44 import org.tmatesoft.svn.core.internal.util.SVNPathUtil;
45 import org.tmatesoft.svn.core.internal.wc.SVNErrorManager;
46 import org.tmatesoft.svn.core.wc.SVNInfo;
47 import org.tmatesoft.svn.core.wc.SVNRevision;
48 import org.tmatesoft.svn.core.wc2.SvnTarget;
49 import org.tmatesoft.svn.util.SVNLogType;
50
51 import javax.swing.*;
52 import javax.swing.table.TableCellRenderer;
53 import java.awt.*;
54 import java.awt.event.MouseEvent;
55 import java.nio.charset.Charset;
56 import java.util.Collections;
57 import java.util.Date;
58 import java.util.List;
59
60 public class SvnHistoryProvider
61   implements VcsHistoryProvider, VcsCacheableHistorySessionFactory<Boolean, SvnHistorySession> {
62   private final SvnVcs myVcs;
63
64   public SvnHistoryProvider(SvnVcs vcs) {
65     myVcs = vcs;
66   }
67
68   @Override
69   public boolean supportsHistoryForDirectories() {
70     return true;
71   }
72
73   @Override
74   public DiffFromHistoryHandler getHistoryDiffHandler() {
75     return null;
76   }
77
78   @Override
79   public boolean canShowHistoryFor(@NotNull VirtualFile file) {
80     return true;
81   }
82
83   @Override
84   public VcsDependentHistoryComponents getUICustomization(final VcsHistorySession session, JComponent forShortcutRegistration) {
85     final ColumnInfo[] columns;
86     final Consumer<VcsFileRevision> listener;
87     final JComponent addComp;
88     if (((SvnHistorySession)session).isHaveMergeSources()) {
89       final MergeSourceColumnInfo mergeSourceColumn = new MergeSourceColumnInfo((SvnHistorySession)session);
90       columns = new ColumnInfo[]{new CopyFromColumnInfo(), mergeSourceColumn};
91
92       final JPanel panel = new JPanel(new BorderLayout());
93
94       final JTextArea field = new JTextArea();
95       field.setEditable(false);
96       field.setBackground(UIUtil.getComboBoxDisabledBackground());
97       field.setWrapStyleWord(true);
98       listener = new Consumer<VcsFileRevision>() {
99         @Override
100         public void consume(VcsFileRevision vcsFileRevision) {
101           field.setText(mergeSourceColumn.getText(vcsFileRevision));
102         }
103       };
104
105       final MergeSourceDetailsAction sourceAction = new MergeSourceDetailsAction();
106       sourceAction.registerSelf(forShortcutRegistration);
107
108       JPanel fieldPanel = new ToolbarDecorator() {
109         @Override
110         protected JComponent getComponent() {
111           return field;
112         }
113
114         @Override
115         protected void updateButtons() {
116         }
117
118         @Override
119         protected void installDnDSupport() {
120         }
121
122         @Override
123         protected boolean isModelEditable() {
124           return false;
125         }
126       }.initPosition()
127         .addExtraAction(AnActionButton.fromAction(sourceAction))
128         .createPanel();
129       fieldPanel.setBorder(IdeBorderFactory.createBorder(SideBorder.LEFT | SideBorder.TOP));
130
131       panel.add(fieldPanel, BorderLayout.CENTER);
132       panel.add(new JLabel("Merge Sources:"), BorderLayout.NORTH);
133       addComp = panel;
134     }
135     else {
136       columns = new ColumnInfo[]{new CopyFromColumnInfo()};
137       addComp = null;
138       listener = null;
139     }
140     return new VcsDependentHistoryComponents(columns, listener, addComp);
141   }
142
143   @Override
144   public FilePath getUsedFilePath(SvnHistorySession session) {
145     return session.getCommittedPath();
146   }
147
148   @Override
149   public Boolean getAddinionallyCachedData(SvnHistorySession session) {
150     return session.isHaveMergeSources();
151   }
152
153   @Override
154   public SvnHistorySession createFromCachedData(Boolean aBoolean,
155                                                @NotNull List<VcsFileRevision> revisions,
156                                                @NotNull FilePath filePath,
157                                                VcsRevisionNumber currentRevision) {
158     return new SvnHistorySession(myVcs, revisions, filePath, aBoolean, currentRevision, false, ! filePath.isNonLocal());
159   }
160
161   @Override
162   @Nullable
163   public VcsHistorySession createSessionFor(final FilePath filePath) throws VcsException {
164     final VcsAppendableHistoryPartnerAdapter adapter = new VcsAppendableHistoryPartnerAdapter();
165     reportAppendableHistory(filePath, adapter);
166     adapter.check();
167
168     return adapter.getSession();
169   }
170
171   @Override
172   public void reportAppendableHistory(FilePath path, final VcsAppendableHistorySessionPartner partner) throws VcsException {
173     // we need + 1 rows to be reported to further detect that number of rows exceeded the limit
174     reportAppendableHistory(path, partner, null, null, VcsConfiguration.getInstance(myVcs.getProject()).MAXIMUM_HISTORY_ROWS + 1, null, false);
175   }
176
177   public void reportAppendableHistory(FilePath path, final VcsAppendableHistorySessionPartner partner,
178                                       @Nullable final SVNRevision from, @Nullable final SVNRevision to, final int limit,
179                                       SVNRevision peg, final boolean forceBackwards) throws VcsException {
180     FilePath committedPath = path;
181     Change change = ChangeListManager.getInstance(myVcs.getProject()).getChange(path);
182     if (change != null) {
183       final ContentRevision beforeRevision = change.getBeforeRevision();
184       final ContentRevision afterRevision = change.getAfterRevision();
185       if (beforeRevision != null && afterRevision != null && !beforeRevision.getFile().equals(afterRevision.getFile()) &&
186           afterRevision.getFile().equals(path)) {
187         committedPath = beforeRevision.getFile();
188       }
189       // revision can be VcsRevisionNumber.NULL
190       if (peg == null && change.getBeforeRevision() != null && change.getBeforeRevision().getRevisionNumber() instanceof SvnRevisionNumber) {
191         peg = ((SvnRevisionNumber) change.getBeforeRevision().getRevisionNumber()).getRevision();
192       }
193     }
194
195     final boolean showMergeSources = SvnConfiguration.getInstance(myVcs.getProject()).isShowMergeSourcesInAnnotate();
196     final LogLoader logLoader;
197     if (path.isNonLocal()) {
198       logLoader = new RepositoryLoader(myVcs, committedPath, from, to, limit, peg, forceBackwards, showMergeSources);
199     }
200     else {
201       logLoader = new LocalLoader(myVcs, committedPath, from, to, limit, peg, showMergeSources);
202     }
203
204     try {
205       logLoader.preliminary();
206     }
207     catch (SVNCancelException e) {
208       throw new VcsException(e);
209     }
210     catch (SVNException e) {
211       throw new VcsException(e);
212     }
213     logLoader.check();
214     if (showMergeSources) {
215       logLoader.initSupports15();
216     }
217
218     final SvnHistorySession historySession =
219       new SvnHistorySession(myVcs, Collections.<VcsFileRevision>emptyList(), committedPath, showMergeSources && Boolean.TRUE.equals(logLoader.mySupport15), null, false,
220                             ! path.isNonLocal());
221
222     final Ref<Boolean> sessionReported = new Ref<Boolean>();
223     final ProgressIndicator indicator = ProgressManager.getInstance().getProgressIndicator();
224     if (indicator != null) {
225       indicator.setText(SvnBundle.message("progress.text2.collecting.history", path.getName()));
226     }
227     final Consumer<VcsFileRevision> consumer = new Consumer<VcsFileRevision>() {
228       @Override
229       public void consume(VcsFileRevision vcsFileRevision) {
230         if (!Boolean.TRUE.equals(sessionReported.get())) {
231           partner.reportCreatedEmptySession(historySession);
232           sessionReported.set(true);
233         }
234         partner.acceptRevision(vcsFileRevision);
235       }
236     };
237
238     logLoader.setConsumer(consumer);
239     logLoader.load();
240     logLoader.check();
241   }
242
243   private static abstract class LogLoader {
244     protected final boolean myShowMergeSources;
245     protected String myUrl;
246     protected boolean mySupport15;
247     protected final SvnVcs myVcs;
248     protected final FilePath myFile;
249     protected final SVNRevision myFrom;
250     protected final SVNRevision myTo;
251     protected final int myLimit;
252     protected final SVNRevision myPeg;
253     protected Consumer<VcsFileRevision> myConsumer;
254     protected final ProgressIndicator myPI;
255     protected VcsException myException;
256
257     protected LogLoader(SvnVcs vcs, FilePath file, SVNRevision from, SVNRevision to, int limit, SVNRevision peg, boolean showMergeSources) {
258       myVcs = vcs;
259       myFile = file;
260       myFrom = from;
261       myTo = to;
262       myLimit = limit;
263       myPeg = peg;
264       myPI = ProgressManager.getInstance().getProgressIndicator();
265       myShowMergeSources = showMergeSources;
266     }
267
268     public void setConsumer(Consumer<VcsFileRevision> consumer) {
269       myConsumer = consumer;
270     }
271
272     protected void initSupports15() {
273       assert myUrl != null;
274       mySupport15 = SvnUtil.checkRepositoryVersion15(myVcs, myUrl);
275     }
276
277     public void check() throws VcsException {
278       if (myException != null) throw myException;
279     }
280
281     protected abstract void preliminary() throws SVNException;
282
283     protected abstract void load();
284   }
285
286   private static class LocalLoader extends LogLoader {
287     private SVNInfo myInfo;
288
289     private LocalLoader(SvnVcs vcs, FilePath file, SVNRevision from, SVNRevision to, int limit, SVNRevision peg, boolean showMergeSources) {
290       super(vcs, file, from, to, limit, peg, showMergeSources);
291     }
292
293     @Override
294     protected void preliminary() throws SVNException {
295       myInfo = myVcs.getInfo(myFile.getIOFile());
296       if (myInfo == null || myInfo.getRepositoryRootURL() == null) {
297         myException = new VcsException("File " + myFile.getPath() + " is not under version control");
298         return;
299       }
300       if (myInfo.getURL() == null) {
301         myException = new VcsException("File " + myFile.getPath() + " is not under Subversion control");
302         return;
303       }
304       myUrl = myInfo.getURL().toDecodedString();
305     }
306
307     @Override
308     protected void load() {
309       String relativeUrl = myUrl;
310       final SVNURL repoRootURL = myInfo.getRepositoryRootURL();
311
312       final String root = repoRootURL.toString();
313       if (myUrl != null && myUrl.startsWith(root)) {
314         relativeUrl = myUrl.substring(root.length());
315       }
316       if (myPI != null) {
317         myPI.setText2(SvnBundle.message("progress.text2.changes.establishing.connection", myUrl));
318       }
319       final SVNRevision pegRevision = myInfo.getRevision();
320       final SvnTarget target = SvnTarget.fromFile(myFile.getIOFile(), myPeg);
321       try {
322         myVcs.getFactory(target).createHistoryClient().doLog(
323           target,
324           myFrom == null ? SVNRevision.HEAD : myFrom,
325           myTo == null ? SVNRevision.create(1) : myTo,
326           false, true, myShowMergeSources && mySupport15, myLimit + 1, null,
327           new MyLogEntryHandler(myVcs, myUrl, pegRevision, relativeUrl,
328                                 createConsumerAdapter(myConsumer),
329                                 repoRootURL, myFile.getCharset()));
330       }
331       catch (SVNCancelException e) {
332         //
333       }
334       catch (SVNException e) {
335         myException = new VcsException(e);
336       }
337       catch (VcsException e) {
338         myException = e;
339       }
340     }
341   }
342
343   private static ThrowableConsumer<VcsFileRevision, SVNException> createConsumerAdapter(final Consumer<VcsFileRevision> consumer) {
344     return new ThrowableConsumer<VcsFileRevision, SVNException>() {
345       @Override
346       public void consume(VcsFileRevision revision) throws SVNException {
347         consumer.consume(revision);
348       }
349     };
350   }
351
352   private static class RepositoryLoader extends LogLoader {
353     private final boolean myForceBackwards;
354
355     private RepositoryLoader(SvnVcs vcs,
356                              FilePath file,
357                              SVNRevision from,
358                              SVNRevision to,
359                              int limit,
360                              SVNRevision peg,
361                              boolean forceBackwards, boolean showMergeSources) {
362       super(vcs, file, from, to, limit, peg, showMergeSources);
363       myForceBackwards = forceBackwards;
364     }
365
366     @Override
367     protected void preliminary() throws SVNException {
368       myUrl = myFile.getPath().replace('\\', '/');
369     }
370
371     @Override
372     protected void load() {
373       if (myPI != null) {
374         myPI.setText2(SvnBundle.message("progress.text2.changes.establishing.connection", myUrl));
375       }
376
377       try {
378         if (myForceBackwards) {
379           SVNURL svnurl = SVNURL.parseURIEncoded(myUrl);
380           if (! existsNow(svnurl)) {
381             loadBackwards(svnurl);
382             return;
383           }
384         }
385
386         final SVNURL svnurl = SVNURL.parseURIEncoded(myUrl);
387         SVNRevision operationalFrom = myFrom == null ? SVNRevision.HEAD : myFrom;
388         // TODO: try to rewrite without separately retrieving repository url by item url - as this command could require authentication
389         // TODO: and it is not "clear enough/easy to implement" with current design (for some cases) how to cache credentials (if in
390         // TODO: non-interactive mode)
391         final SVNURL rootURL = SvnUtil.getRepositoryRoot(myVcs, svnurl);
392         if (rootURL == null) {
393           throw new VcsException("Could not find repository root for URL: " + myUrl);
394         }
395         final String root = rootURL.toString();
396         String relativeUrl = myUrl;
397         if (myUrl.startsWith(root)) {
398           relativeUrl = myUrl.substring(root.length());
399         }
400         SvnTarget target = SvnTarget.fromURL(svnurl, myPeg == null ? myFrom : myPeg);
401         RepositoryLogEntryHandler handler =
402           new RepositoryLogEntryHandler(myVcs, myUrl, SVNRevision.UNDEFINED, relativeUrl, createConsumerAdapter(myConsumer), rootURL);
403
404         myVcs.getFactory(target).createHistoryClient()
405           .doLog(target, operationalFrom, myTo == null ? SVNRevision.create(1) : myTo, false, true, myShowMergeSources && mySupport15,
406                  myLimit + 1, null, handler);
407       }
408       catch (SVNCancelException e) {
409         //
410       }
411       catch (SVNException e) {
412         myException = new VcsException(e);
413       }
414       catch (VcsException e) {
415         myException = e;
416       }
417     }
418
419     private void loadBackwards(SVNURL svnurl) throws SVNException, VcsException {
420       // this method is called when svnurl does not exist in latest repository revision - thus concrete old revision is used for "info"
421       // command to get repository url
422       SVNInfo info = myVcs.getInfo(svnurl, myPeg, myPeg);
423       final SVNURL rootURL = info != null ? info.getRepositoryRootURL() : null;
424       final String root = rootURL != null ? rootURL.toString() : "";
425       String relativeUrl = myUrl;
426       if (myUrl.startsWith(root)) {
427         relativeUrl = myUrl.substring(root.length());
428       }
429
430       final RepositoryLogEntryHandler repositoryLogEntryHandler =
431           new RepositoryLogEntryHandler(myVcs, myUrl, SVNRevision.UNDEFINED, relativeUrl,
432                                         new ThrowableConsumer<VcsFileRevision, SVNException>() {
433                                           @Override
434                                           public void consume(VcsFileRevision revision) throws SVNException {
435                                             myConsumer.consume(revision);
436                                           }
437                                         }, rootURL);
438       repositoryLogEntryHandler.setThrowCancelOnMeetPathCreation(true);
439
440       SvnTarget target = SvnTarget.fromURL(rootURL, myFrom);
441       myVcs.getFactory(target).createHistoryClient()
442         .doLog(target, myFrom, myTo == null ? SVNRevision.create(1) : myTo, false, true, myShowMergeSources && mySupport15, 1, null,
443                repositoryLogEntryHandler);
444     }
445
446     private boolean existsNow(SVNURL svnurl) {
447       final SVNInfo info;
448       try {
449         info = myVcs.getInfo(svnurl, SVNRevision.HEAD, SVNRevision.HEAD);
450       }
451       catch (SVNException e) {
452         return false;
453       }
454       return info != null && info.getURL() != null && info.getRevision().isValid();
455     }
456   }
457
458   @Override
459   public String getHelpId() {
460     return null;
461   }
462
463   @Override
464   public AnAction[] getAdditionalActions(final Runnable refresher) {
465     return new AnAction[]{ ShowAllAffectedGenericAction.getInstance(), new MergeSourceDetailsAction(), new SvnEditCommitMessageFromFileHistoryAction()};
466   }
467
468   @Override
469   public boolean isDateOmittable() {
470     return false;
471   }
472
473   private static class MyLogEntryHandler implements ISVNLogEntryHandler {
474     private final ProgressIndicator myIndicator;
475     protected final SvnVcs myVcs;
476     protected final SvnPathThroughHistoryCorrection myLastPathCorrector;
477     private final Charset myCharset;
478     protected final ThrowableConsumer<VcsFileRevision, SVNException> myResult;
479     private final String myLastPath;
480     private VcsFileRevision myPrevious;
481     private final SVNRevision myPegRevision;
482     protected final String myUrl;
483     private final SvnMergeSourceTracker myTracker;
484     protected SVNURL myRepositoryRoot;
485     private boolean myThrowCancelOnMeetPathCreation;
486
487     public void setThrowCancelOnMeetPathCreation(boolean throwCancelOnMeetPathCreation) {
488       myThrowCancelOnMeetPathCreation = throwCancelOnMeetPathCreation;
489     }
490
491     public MyLogEntryHandler(SvnVcs vcs, final String url,
492                              final SVNRevision pegRevision,
493                              String lastPath,
494                              final ThrowableConsumer<VcsFileRevision, SVNException> result,
495                              SVNURL repoRootURL, Charset charset)
496       throws SVNException, VcsException {
497       myVcs = vcs;
498       myLastPathCorrector = new SvnPathThroughHistoryCorrection(lastPath);
499       myLastPath = lastPath;
500       myCharset = charset;
501       myIndicator = ProgressManager.getInstance().getProgressIndicator();
502       myResult = result;
503       myPegRevision = pegRevision;
504       myUrl = url;
505       myRepositoryRoot = repoRootURL;
506       myTracker = new SvnMergeSourceTracker(new ThrowableConsumer<Pair<SVNLogEntry, Integer>, SVNException>() {
507         @Override
508         public void consume(final Pair<SVNLogEntry, Integer> svnLogEntryIntegerPair) throws SVNException {
509           final SVNLogEntry logEntry = svnLogEntryIntegerPair.getFirst();
510
511           if (myIndicator != null) {
512             if (myIndicator.isCanceled()) {
513               SVNErrorManager.cancel(SvnBundle.message("exception.text.update.operation.cancelled"), SVNLogType.DEFAULT);
514             }
515             myIndicator.setText2(SvnBundle.message("progress.text2.revision.processed", logEntry.getRevision()));
516           }
517           SVNLogEntryPath entryPath = null;
518           String copyPath = null;
519           final int mergeLevel = svnLogEntryIntegerPair.getSecond();
520
521           if (! myLastPathCorrector.isRoot()) {
522             myLastPathCorrector.handleLogEntry(logEntry);
523             entryPath = myLastPathCorrector.getDirectlyMentioned();
524             copyPath = null;
525             if (entryPath != null) {
526               copyPath = entryPath.getCopyPath();
527             } else {
528               // if there are no path with exact match, check whether parent or child paths had changed
529               // "entry path" is allowed to be null now; if it is null, last path would be taken for revision construction
530
531               // Separate SVNLogEntry is issued for each "merge source" revision. These "merge source" revisions are treated as child
532               // revisions of some other revision - this way we construct merge hierarchy.
533               // mergeLevel >= 0 indicates that we are currently processing some "merge source" revision. This "merge source" revision
534               // contains changes from some other branch - so checkForChildChanges() and checkForParentChanges() return "false".
535               // Because of this case we apply these methods only for non-"merge source" revisions - this means mergeLevel < 0.
536               // TODO: Do not apply path filtering even for log entries on the first level => just output of 'svn log' should be returned.
537               // TODO: Looks like there is no cases when we issue 'svn log' for some parent paths or some other cases where we need such
538               // TODO: filtering. Check user feedback on this.
539 //              if (mergeLevel < 0 && !checkForChildChanges(logEntry) && !checkForParentChanges(logEntry)) return;
540             }
541           }
542
543           final SvnFileRevision revision = createRevision(logEntry, copyPath, entryPath);
544           if (mergeLevel >= 0) {
545             addToListByLevel((SvnFileRevision)myPrevious, revision, mergeLevel);
546           }
547           else {
548             myResult.consume(revision);
549             myPrevious = revision;
550           }
551           if (myThrowCancelOnMeetPathCreation && myUrl.equals(revision.getURL()) && entryPath != null && entryPath.getType() == 'A') {
552             throw new SVNCancelException();
553           }
554         }
555
556       });
557     }
558
559     private boolean checkForParentChanges(SVNLogEntry logEntry) {
560       final String lastPathBefore = myLastPathCorrector.getBefore();
561       String path = SVNPathUtil.removeTail(lastPathBefore);
562       while (path.length() > 0) {
563         final SVNLogEntryPath entryPath = logEntry.getChangedPaths().get(path);
564         // A & D are checked since we are not interested in parent folders property changes, only in structure changes
565         // TODO: seems that R (replaced) should also be checked here
566         if (entryPath != null && (entryPath.getType() == 'A' || entryPath.getType() == 'D')) {
567           if (entryPath.getCopyPath() != null) {
568             return true;
569           }
570           break;
571         }
572         path = SVNPathUtil.removeTail(path);
573       }
574       return false;
575     }
576
577     // TODO: this makes sense only for directories, but should always return true if something under the directory was changed in revision
578     // TODO: as svn will provide child changes in history for directory
579     private boolean checkForChildChanges(SVNLogEntry logEntry) {
580       final String lastPathBefore = myLastPathCorrector.getBefore();
581       for (String key : logEntry.getChangedPaths().keySet()) {
582         if (SVNPathUtil.isAncestor(lastPathBefore, key)) {
583           return true;
584         }
585       }
586       return false;
587     }
588
589     @Override
590     public void handleLogEntry(SVNLogEntry logEntry) throws SVNException {
591       myTracker.consume(logEntry);
592     }
593
594     private static void addToListByLevel(final SvnFileRevision revision, final SvnFileRevision revisionToAdd, final int level) {
595       if (level < 0) {
596         return;
597       }
598       if (level == 0) {
599         revision.addMergeSource(revisionToAdd);
600         return;
601       }
602       final List<SvnFileRevision> sources = revision.getMergeSources();
603       if (!sources.isEmpty()) {
604         addToListByLevel(sources.get(sources.size() - 1), revisionToAdd, level - 1);
605       }
606     }
607
608     protected SvnFileRevision createRevision(final SVNLogEntry logEntry, final String copyPath, SVNLogEntryPath entryPath) throws SVNException {
609       Date date = logEntry.getDate();
610       String author = logEntry.getAuthor();
611       String message = logEntry.getMessage();
612       SVNRevision rev = SVNRevision.create(logEntry.getRevision());
613       final SVNURL url = myRepositoryRoot.appendPath(myLastPath, true);
614 //      final SVNURL url = entryPath != null ? myRepositoryRoot.appendPath(entryPath.getPath(), true) :
615 //                         myRepositoryRoot.appendPath(myLastPathCorrector.getBefore(), false);
616       return new SvnFileRevision(myVcs, myPegRevision, rev, url.toString(), author, date, message, copyPath, myCharset);
617     }
618   }
619
620   private static class RepositoryLogEntryHandler extends MyLogEntryHandler {
621     public RepositoryLogEntryHandler(final SvnVcs vcs, final String url,
622                                      final SVNRevision pegRevision,
623                                      String lastPath,
624                                      final ThrowableConsumer<VcsFileRevision, SVNException> result,
625                                      SVNURL repoRootURL)
626       throws VcsException, SVNException {
627       super(vcs, url, pegRevision, lastPath, result, repoRootURL, null);
628     }
629
630     @Override
631     protected SvnFileRevision createRevision(final SVNLogEntry logEntry, final String copyPath, SVNLogEntryPath entryPath)
632       throws SVNException {
633       final SVNURL url = entryPath == null ? myRepositoryRoot.appendPath(myLastPathCorrector.getBefore(), false) :
634                          myRepositoryRoot.appendPath(entryPath.getPath(), true);
635       return new SvnFileRevision(myVcs, SVNRevision.UNDEFINED, logEntry, url.toString(), copyPath, null);
636     }
637   }
638
639   private static class RevisionMergeSourceInfo {
640
641     @NotNull private final VcsFileRevision revision;
642
643     private RevisionMergeSourceInfo(@NotNull VcsFileRevision revision) {
644       this.revision = revision;
645     }
646
647     @NotNull
648     public SvnFileRevision getRevision() {
649       return (SvnFileRevision)revision;
650     }
651
652     // will be used, for instance, while copying (to clipboard) data from table
653     @Override
654     public String toString() {
655       return toString(revision);
656     }
657
658     private static String toString(@Nullable VcsFileRevision value) {
659       if (!(value instanceof SvnFileRevision)) return "";
660       final SvnFileRevision revision = (SvnFileRevision)value;
661       final List<SvnFileRevision> mergeSources = revision.getMergeSources();
662       if (mergeSources.isEmpty()) {
663         return "";
664       }
665       final StringBuilder sb = new StringBuilder();
666       for (SvnFileRevision source : mergeSources) {
667         if (sb.length() != 0) {
668           sb.append(", ");
669         }
670         sb.append(source.getRevisionNumber().asString());
671         if (!source.getMergeSources().isEmpty()) {
672           sb.append("*");
673         }
674       }
675       return sb.toString();
676     }
677   }
678
679   private class MergeSourceColumnInfo extends ColumnInfo<VcsFileRevision, RevisionMergeSourceInfo> {
680     private final MergeSourceRenderer myRenderer;
681
682     private MergeSourceColumnInfo(final SvnHistorySession session) {
683       super("Merge Sources");
684       myRenderer = new MergeSourceRenderer(session);
685     }
686
687     @Override
688     public TableCellRenderer getRenderer(final VcsFileRevision vcsFileRevision) {
689       return myRenderer;
690     }
691
692     @Override
693     public RevisionMergeSourceInfo valueOf(final VcsFileRevision vcsFileRevision) {
694       return vcsFileRevision != null ? new RevisionMergeSourceInfo(vcsFileRevision) : null;
695     }
696
697     public String getText(final VcsFileRevision vcsFileRevision) {
698       return myRenderer.getText(vcsFileRevision);
699     }
700
701     @Override
702     public int getAdditionalWidth() {
703       return 20;
704     }
705
706     @Override
707     public String getPreferredStringValue() {
708       return "1234567, 1234567, 1234567";
709     }
710   }
711
712   private static final Object MERGE_SOURCE_DETAILS_TAG = new Object();
713
714   private class MergeSourceDetailsLinkListener extends TableLinkMouseListener {
715     private final VirtualFile myFile;
716     private final Object myTag;
717
718     private MergeSourceDetailsLinkListener(final Object tag, final VirtualFile file) {
719       myTag = tag;
720       myFile = file;
721     }
722
723     @Override
724     public boolean onClick(@NotNull MouseEvent e, int clickCount) {
725       if (e.getButton() == 1 && !e.isPopupTrigger()) {
726         Object tag = getTagAt(e);
727         if (tag == myTag) {
728           final SvnFileRevision revision = getSelectedRevision(e);
729           if (revision != null) {
730             SvnMergeSourceDetails.showMe(myVcs.getProject(), revision, myFile);
731             return true;
732           }
733         }
734       }
735       return false;
736     }
737
738     @Nullable
739     private SvnFileRevision getSelectedRevision(final MouseEvent e) {
740       JTable table = (JTable)e.getSource();
741       int row = table.rowAtPoint(e.getPoint());
742       int column = table.columnAtPoint(e.getPoint());
743
744       final Object value = table.getModel().getValueAt(row, column);
745       if (value instanceof RevisionMergeSourceInfo) {
746         return ((RevisionMergeSourceInfo)value).getRevision();
747       }
748       return null;
749     }
750
751     @Override
752     public void mouseMoved(MouseEvent e) {
753       JTable table = (JTable)e.getSource();
754       Object tag = getTagAt(e);
755       if (tag == myTag) {
756         table.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
757       }
758       else {
759         table.setCursor(Cursor.getDefaultCursor());
760       }
761     }
762   }
763
764   private class MergeSourceRenderer extends ColoredTableCellRenderer {
765     private MergeSourceDetailsLinkListener myListener;
766     private final VirtualFile myFile;
767
768     private MergeSourceRenderer(final SvnHistorySession session) {
769       myFile = session.getCommittedPath().getVirtualFile();
770     }
771
772     public String getText(final VcsFileRevision value) {
773       return RevisionMergeSourceInfo.toString(value);
774     }
775
776     @Override
777     protected void customizeCellRenderer(final JTable table,
778                                          final Object value,
779                                          final boolean selected,
780                                          final boolean hasFocus,
781                                          final int row,
782                                          final int column) {
783       if (myListener == null) {
784         myListener = new MergeSourceDetailsLinkListener(MERGE_SOURCE_DETAILS_TAG, myFile);
785         myListener.installOn(table);
786       }
787       appendMergeSourceText(table, row, column, value instanceof RevisionMergeSourceInfo ? value.toString() : null);
788     }
789
790     private void appendMergeSourceText(JTable table, int row, int column, @Nullable String text) {
791       if (StringUtil.isEmpty(text)) {
792         append("", SimpleTextAttributes.REGULAR_ATTRIBUTES);
793       }
794       else {
795         append(cutString(text, table.getCellRect(row, column, false).getWidth()), SimpleTextAttributes.REGULAR_ATTRIBUTES,
796                MERGE_SOURCE_DETAILS_TAG);
797       }
798     }
799
800     private String cutString(final String text, final double value) {
801       final FontMetrics m = getFontMetrics(getFont());
802       final Graphics g = getGraphics();
803
804       if (m.getStringBounds(text, g).getWidth() < value) return text;
805
806       final String dots = "...";
807       final double dotsWidth = m.getStringBounds(dots, g).getWidth();
808       if (dotsWidth >= value) {
809         return dots;
810       }
811
812       for (int i = 1; i < text.length(); i++) {
813         if ((m.getStringBounds(text, 0, i, g).getWidth() + dotsWidth) >= value) {
814           if (i < 2) return dots;
815           return text.substring(0, i - 1) + dots;
816         }
817       }
818       return text;
819     }
820   }
821
822   private static class CopyFromColumnInfo extends ColumnInfo<VcsFileRevision, String> {
823     private final Icon myIcon = PlatformIcons.COPY_ICON;
824     private final ColoredTableCellRenderer myRenderer = new ColoredTableCellRenderer() {
825       @Override
826       protected void customizeCellRenderer(final JTable table,
827                                            final Object value,
828                                            final boolean selected,
829                                            final boolean hasFocus,
830                                            final int row,
831                                            final int column) {
832         if (value instanceof String && ((String)value).length() > 0) {
833           setIcon(myIcon);
834           setToolTipText(SvnBundle.message("copy.column.tooltip", value));
835         }
836         else {
837           setToolTipText("");
838         }
839       }
840     };
841
842     public CopyFromColumnInfo() {
843       super(SvnBundle.message("copy.column.title"));
844     }
845
846     @Override
847     public String valueOf(final VcsFileRevision o) {
848       return o instanceof SvnFileRevision ? ((SvnFileRevision)o).getCopyFromPath() : "";
849     }
850
851     @Override
852     public TableCellRenderer getRenderer(final VcsFileRevision vcsFileRevision) {
853       return myRenderer;
854     }
855
856     @Override
857     public String getMaxStringValue() {
858       return SvnBundle.message("copy.column.title");
859     }
860
861     @Override
862     public int getAdditionalWidth() {
863       return 6;
864     }
865   }
866 }