blob: 96de4a0a1108f060dd3b7314770f11f1761b0d57 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2011, 2016 GitHub Inc. and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Kevin Sawicki (GitHub Inc.) - initial API and implementation
* Thomas Wolf <thomas.wolf@paranor.ch> - turn it into a real text editor
*******************************************************************************/
package org.eclipse.egit.ui.internal.commit;
import static java.util.Arrays.asList;
import static org.eclipse.egit.ui.UIPreferences.THEME_DiffAddBackgroundColor;
import static org.eclipse.egit.ui.UIPreferences.THEME_DiffRemoveBackgroundColor;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.eclipse.core.resources.IMarker;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.SubMonitor;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.egit.core.AdapterUtils;
import org.eclipse.egit.ui.Activator;
import org.eclipse.egit.ui.UIUtils;
import org.eclipse.egit.ui.internal.UIText;
import org.eclipse.egit.ui.internal.commit.DiffRegionFormatter.DiffRegion;
import org.eclipse.egit.ui.internal.commit.DiffRegionFormatter.FileDiffRegion;
import org.eclipse.egit.ui.internal.history.FileDiff;
import org.eclipse.egit.ui.internal.repository.RepositoriesView;
import org.eclipse.jface.action.Action;
import org.eclipse.jface.action.ActionContributionItem;
import org.eclipse.jface.action.IAction;
import org.eclipse.jface.action.IContributionItem;
import org.eclipse.jface.action.IMenuManager;
import org.eclipse.jface.operation.IRunnableContext;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.preference.PreferenceStore;
import org.eclipse.jface.resource.ColorRegistry;
import org.eclipse.jface.resource.StringConverter;
import org.eclipse.jface.text.Document;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.reconciler.IReconciler;
import org.eclipse.jface.text.source.Annotation;
import org.eclipse.jface.text.source.AnnotationModel;
import org.eclipse.jface.text.source.IAnnotationModel;
import org.eclipse.jface.text.source.IAnnotationModelExtension;
import org.eclipse.jface.text.source.ISourceViewer;
import org.eclipse.jface.text.source.IVerticalRuler;
import org.eclipse.jface.text.source.IVerticalRulerColumn;
import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
import org.eclipse.jface.text.source.projection.ProjectionSupport;
import org.eclipse.jface.text.source.projection.ProjectionViewer;
import org.eclipse.jface.util.IPropertyChangeListener;
import org.eclipse.jface.viewers.ISelection;
import org.eclipse.jface.viewers.StructuredSelection;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.swt.graphics.RGB;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
import org.eclipse.team.ui.history.IHistoryView;
import org.eclipse.ui.IEditorInput;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.editors.text.EditorsUI;
import org.eclipse.ui.editors.text.TextEditor;
import org.eclipse.ui.forms.IManagedForm;
import org.eclipse.ui.forms.editor.FormEditor;
import org.eclipse.ui.forms.editor.IFormPage;
import org.eclipse.ui.ide.IDE;
import org.eclipse.ui.part.IShowInSource;
import org.eclipse.ui.part.IShowInTargetList;
import org.eclipse.ui.part.ShowInContext;
import org.eclipse.ui.progress.UIJob;
import org.eclipse.ui.texteditor.AbstractDecoratedTextEditorPreferenceConstants;
import org.eclipse.ui.texteditor.AbstractDocumentProvider;
import org.eclipse.ui.texteditor.ChainedPreferenceStore;
import org.eclipse.ui.texteditor.IDocumentProvider;
import org.eclipse.ui.texteditor.ITextEditorActionConstants;
import org.eclipse.ui.themes.IThemeManager;
import org.eclipse.ui.views.contentoutline.IContentOutlinePage;
/**
* A {@link TextEditor} wrapped as an {@link IFormPage} and specialized to
* showing a unified diff of a whole commit. The editor has an associated
* {@link DiffEditorOutlinePage}.
*/
public class DiffEditorPage extends TextEditor
implements IFormPage, IShowInSource, IShowInTargetList {
private static final String ADD_ANNOTATION_TYPE = "org.eclipse.egit.ui.commitEditor.diffAdded"; //$NON-NLS-1$
private static final String REMOVE_ANNOTATION_TYPE = "org.eclipse.egit.ui.commitEditor.diffRemoved"; //$NON-NLS-1$
private FormEditor formEditor;
private String title;
private String pageId;
private int pageIndex = -1;
private Control textControl;
private DiffEditorOutlinePage outlinePage;
private Annotation[] currentFoldingAnnotations;
private Annotation[] currentOverviewAnnotations;
/** An {@link IPreferenceStore} for the annotation colors.*/
private ThemePreferenceStore overviewStore;
private FileDiffRegion currentFileDiffRange;
private OldNewLogicalLineNumberRulerColumn lineNumberColumn;
private boolean plainLineNumbers = false;
/**
* Creates a new {@link DiffEditorPage} with the given id and title, which
* is shown on the page's tab.
*
* @param editor
* containing this page
* @param id
* of the page
* @param title
* of the page
*/
public DiffEditorPage(FormEditor editor, String id, String title) {
pageId = id;
this.title = title;
setPartName(title);
initialize(editor);
}
/**
* Creates a new {@link DiffEditorPage} with default id and title.
*
* @param editor
* containing the page
*/
public DiffEditorPage(FormEditor editor) {
this(editor, "diffPage", UIText.DiffEditorPage_Title); //$NON-NLS-1$
}
@SuppressWarnings("unchecked")
@Override
public Object getAdapter(Class adapter) {
// TODO Switch to generified signature once EGit's base dependency is
// Eclipse 4.6
if (IContentOutlinePage.class.equals(adapter)) {
if (outlinePage == null) {
outlinePage = createOutlinePage();
outlinePage.setInput(
getDocumentProvider().getDocument(getEditorInput()));
if (currentFileDiffRange != null) {
outlinePage.setSelection(
new StructuredSelection(currentFileDiffRange));
}
}
return outlinePage;
}
return super.getAdapter(adapter);
}
private DiffEditorOutlinePage createOutlinePage() {
DiffEditorOutlinePage page = new DiffEditorOutlinePage();
page.addSelectionChangedListener(
event -> doSetSelection(event.getSelection()));
page.addOpenListener(event -> {
FormEditor editor = getEditor();
editor.getSite().getPage().activate(editor);
editor.setActivePage(getId());
doSetSelection(event.getSelection());
});
return page;
}
@Override
public void dispose() {
// Nested editors are responsible for disposing their outline pages
// themselves.
if (outlinePage != null) {
outlinePage.dispose();
outlinePage = null;
}
if (overviewStore != null) {
overviewStore.dispose();
overviewStore = null;
}
super.dispose();
}
// TextEditor specifics:
@Override
protected ISourceViewer createSourceViewer(Composite parent,
IVerticalRuler ruler, int styles) {
DiffViewer viewer = new DiffViewer(parent, ruler, getOverviewRuler(),
isOverviewRulerVisible(), styles);
getSourceViewerDecorationSupport(viewer);
ProjectionSupport projector = new ProjectionSupport(viewer,
getAnnotationAccess(), getSharedColors());
projector.install();
viewer.getTextWidget().addCaretListener(event -> {
if (outlinePage != null) {
FileDiffRegion region = getFileDiffRange(event.caretOffset);
if (region != null && !region.equals(currentFileDiffRange)) {
currentFileDiffRange = region;
outlinePage.setSelection(new StructuredSelection(region));
} else {
currentFileDiffRange = region;
}
}
});
return viewer;
}
@Override
protected IVerticalRulerColumn createLineNumberRulerColumn() {
lineNumberColumn = new OldNewLogicalLineNumberRulerColumn(
plainLineNumbers);
initializeLineNumberRulerColumn(lineNumberColumn);
return lineNumberColumn;
}
@Override
protected void initializeEditor() {
super.initializeEditor();
overviewStore = new ThemePreferenceStore();
setPreferenceStore(new ChainedPreferenceStore(new IPreferenceStore[] {
overviewStore, EditorsUI.getPreferenceStore() }));
setDocumentProvider(new DiffDocumentProvider());
setSourceViewerConfiguration(
new DiffViewer.Configuration(getPreferenceStore()) {
@Override
public IReconciler getReconciler(
ISourceViewer sourceViewer) {
// Switch off spell-checking
return null;
}
});
}
@Override
protected void doSetInput(IEditorInput input) throws CoreException {
super.doSetInput(input);
if (input instanceof DiffEditorInput) {
setFolding();
FileDiffRegion region = getFileDiffRange(0);
currentFileDiffRange = region;
setOverviewAnnotations();
} else if (input instanceof CommitEditorInput) {
formatDiff();
currentFileDiffRange = null;
}
if (outlinePage != null) {
outlinePage.setInput(getDocumentProvider().getDocument(input));
if (currentFileDiffRange != null) {
outlinePage.setSelection(
new StructuredSelection(currentFileDiffRange));
}
}
}
@Override
protected void doSetSelection(ISelection selection) {
if (!selection.isEmpty() && selection instanceof StructuredSelection) {
Object selected = ((StructuredSelection) selection)
.getFirstElement();
if (selected instanceof FileDiffRegion) {
FileDiffRegion newRange = (FileDiffRegion) selected;
if (!newRange.equals(currentFileDiffRange)) {
currentFileDiffRange = newRange;
selectAndReveal(newRange.getOffset(), 0);
}
return;
}
}
super.doSetSelection(selection);
}
@Override
protected void editorContextMenuAboutToShow(IMenuManager menu) {
super.editorContextMenuAboutToShow(menu);
addAction(menu, ITextEditorActionConstants.GROUP_COPY,
ITextEditorActionConstants.SELECT_ALL);
// TextEditor always adds these, even if the document is not editable.
menu.remove(ITextEditorActionConstants.SHIFT_RIGHT);
menu.remove(ITextEditorActionConstants.SHIFT_LEFT);
}
@Override
protected void rulerContextMenuAboutToShow(IMenuManager menu) {
super.rulerContextMenuAboutToShow(menu);
// AbstractDecoratedTextEditor's menu presumes a
// LineNumberChangeRulerColumn, which we don't have.
IContributionItem showLineNumbers = menu
.find(ITextEditorActionConstants.LINENUMBERS_TOGGLE);
boolean isShowingLineNumbers = EditorsUI.getPreferenceStore()
.getBoolean(
AbstractDecoratedTextEditorPreferenceConstants.EDITOR_LINE_NUMBER_RULER);
if (showLineNumbers instanceof ActionContributionItem) {
((ActionContributionItem) showLineNumbers).getAction()
.setChecked(isShowingLineNumbers);
}
if (isShowingLineNumbers) {
// Add an action to toggle between physical and logical line numbers
boolean plain = lineNumberColumn.isPlain();
IAction togglePlain = new Action(
UIText.DiffEditorPage_ToggleLineNumbers,
IAction.AS_CHECK_BOX) {
@Override
public void run() {
plainLineNumbers = !plain;
lineNumberColumn.setPlain(!plain);
}
};
togglePlain.setChecked(!plain);
menu.appendToGroup(ITextEditorActionConstants.GROUP_RULERS,
togglePlain);
}
}
// FormPage specifics:
@Override
public void initialize(FormEditor editor) {
formEditor = editor;
}
@Override
public FormEditor getEditor() {
return formEditor;
}
@Override
public IManagedForm getManagedForm() {
return null;
}
@Override
public void setActive(boolean active) {
if (active) {
setFocus();
}
}
@Override
public boolean isActive() {
return this.equals(formEditor.getActivePageInstance());
}
@Override
public boolean canLeaveThePage() {
return true;
}
@Override
public Control getPartControl() {
return textControl;
}
@Override
public String getId() {
return pageId;
}
@Override
public int getIndex() {
return pageIndex;
}
@Override
public void setIndex(int index) {
pageIndex = index;
}
@Override
public boolean isEditor() {
return true;
}
@Override
public boolean selectReveal(Object object) {
if (object instanceof IMarker) {
IDE.gotoMarker(this, (IMarker) object);
return true;
} else if (object instanceof ISelection) {
doSetSelection((ISelection) object);
return true;
}
return false;
}
// WorkbenchPart specifics:
@Override
public String getTitle() {
return title;
}
@Override
public void createPartControl(Composite parent) {
super.createPartControl(parent);
Control[] children = parent.getChildren();
textControl = children[children.length - 1];
}
// "Show In..." specifics:
@Override
public ShowInContext getShowInContext() {
RepositoryCommit commit = AdapterUtils.adapt(getEditorInput(),
RepositoryCommit.class);
if (commit != null) {
return new ShowInContext(getEditorInput(),
new StructuredSelection(commit));
}
return null;
}
@Override
public String[] getShowInTargetIds() {
return new String[] { IHistoryView.VIEW_ID, RepositoriesView.VIEW_ID };
}
// Diff specifics:
private void setFolding() {
ProjectionViewer viewer = (ProjectionViewer) getSourceViewer();
if (viewer == null) {
return;
}
IDocument document = viewer.getDocument();
if (document instanceof DiffDocument) {
FileDiffRegion[] regions = ((DiffDocument) document)
.getFileRegions();
if (regions == null || regions.length <= 1) {
viewer.disableProjection();
return;
}
viewer.enableProjection();
Map<Annotation, Position> newAnnotations = new HashMap<>();
for (FileDiffRegion region : regions) {
newAnnotations.put(new ProjectionAnnotation(),
new Position(region.getOffset(), region.getLength()));
}
viewer.getProjectionAnnotationModel().modifyAnnotations(
currentFoldingAnnotations, newAnnotations, null);
currentFoldingAnnotations = newAnnotations.keySet()
.toArray(new Annotation[newAnnotations.size()]);
} else {
viewer.disableProjection();
currentFoldingAnnotations = null;
}
}
private void setOverviewAnnotations() {
IDocumentProvider documentProvider = getDocumentProvider();
IDocument document = documentProvider.getDocument(getEditorInput());
if (!(document instanceof DiffDocument)) {
return;
}
IAnnotationModel annotationModel = documentProvider
.getAnnotationModel(getEditorInput());
if (annotationModel == null) {
return;
}
DiffRegion[] diffs = ((DiffDocument) document).getRegions();
if (diffs == null || diffs.length == 0) {
return;
}
Map<Annotation, Position> newAnnotations = new HashMap<>();
for (DiffRegion region : diffs) {
if (DiffRegion.Type.ADD.equals(region.getType())) {
newAnnotations.put(
new Annotation(ADD_ANNOTATION_TYPE, true, null),
new Position(region.getOffset(), region.getLength()));
} else if (DiffRegion.Type.REMOVE.equals(region.getType())) {
newAnnotations.put(
new Annotation(REMOVE_ANNOTATION_TYPE, true, null),
new Position(region.getOffset(), region.getLength()));
}
}
if (annotationModel instanceof IAnnotationModelExtension) {
((IAnnotationModelExtension) annotationModel).replaceAnnotations(
currentOverviewAnnotations, newAnnotations);
} else {
if (currentOverviewAnnotations != null) {
for (Annotation existing : currentOverviewAnnotations) {
annotationModel.removeAnnotation(existing);
}
}
for (Map.Entry<Annotation, Position> entry : newAnnotations
.entrySet()) {
annotationModel.addAnnotation(entry.getKey(), entry.getValue());
}
}
currentOverviewAnnotations = newAnnotations.keySet()
.toArray(new Annotation[newAnnotations.size()]);
}
private FileDiffRegion getFileDiffRange(int widgetOffset) {
DiffViewer viewer = (DiffViewer) getSourceViewer();
if (viewer != null) {
int offset = viewer.widgetOffset2ModelOffset(widgetOffset);
IDocument document = getDocumentProvider()
.getDocument(getEditorInput());
if (document instanceof DiffDocument) {
return ((DiffDocument) document).findFileRegion(offset);
}
}
return null;
}
/**
* Gets the full unified diff of a {@link RepositoryCommit}.
*
* @param commit
* to get the diff
* @return the diff as a sorted (by file path) array of {@link FileDiff}s
*/
protected FileDiff[] getDiffs(RepositoryCommit commit) {
List<FileDiff> diffResult = new ArrayList<>();
diffResult.addAll(asList(commit.getDiffs()));
if (commit.getRevCommit().getParentCount() > 2) {
RevCommit untrackedCommit = commit.getRevCommit()
.getParent(StashEditorPage.PARENT_COMMIT_UNTRACKED);
diffResult
.addAll(asList(new RepositoryCommit(commit.getRepository(),
untrackedCommit).getDiffs()));
}
FileDiff[] result = diffResult.toArray(new FileDiff[diffResult.size()]);
Arrays.sort(result, FileDiff.PATH_COMPARATOR);
return result;
}
/**
* Asynchronously gets the diff of the commit set on our
* {@link CommitEditorInput}, formats it into a {@link DiffDocument}, and
* then re-sets this editors's input to a {@link DiffEditorInput} which will
* cause this document to be shown.
*/
private void formatDiff() {
RepositoryCommit commit = AdapterUtils.adapt(getEditor(),
RepositoryCommit.class);
if (commit == null) {
return;
}
if (!commit.isStash() && commit.getRevCommit().getParentCount() > 1) {
setInput(new DiffEditorInput(commit, null));
return;
}
Job job = new Job(UIText.DiffEditorPage_TaskGeneratingDiff) {
@Override
protected IStatus run(IProgressMonitor monitor) {
FileDiff diffs[] = getDiffs(commit);
DiffDocument document = new DiffDocument();
try (DiffRegionFormatter formatter = new DiffRegionFormatter(
document)) {
SubMonitor progress = SubMonitor.convert(monitor,
diffs.length);
Repository repository = commit.getRepository();
for (FileDiff diff : diffs) {
if (progress.isCanceled()) {
break;
}
progress.subTask(diff.getPath());
try {
formatter.write(repository, diff);
} catch (IOException ignore) {
// Ignored
}
progress.worked(1);
}
document.connect(formatter);
}
new UIJob(UIText.DiffEditorPage_TaskUpdatingViewer) {
@Override
public IStatus runInUIThread(IProgressMonitor uiMonitor) {
if (UIUtils.isUsable(getPartControl())) {
setInput(new DiffEditorInput(commit, document));
}
return Status.OK_STATUS;
}
}.schedule();
return Status.OK_STATUS;
}
};
job.schedule();
}
/**
* An editor input that gives access to the document created by the diff
* formatter.
*/
private static class DiffEditorInput extends CommitEditorInput {
private IDocument document;
public DiffEditorInput(RepositoryCommit commit, IDocument diff) {
super(commit);
document = diff;
}
public IDocument getDocument() {
return document;
}
@Override
public String getName() {
return UIText.DiffEditorPage_Title;
}
@Override
public boolean equals(Object obj) {
return super.equals(obj) && (obj instanceof DiffEditorInput)
&& Objects.equals(document,
((DiffEditorInput) obj).document);
}
@Override
public int hashCode() {
return super.hashCode() ^ Objects.hashCode(document);
}
}
/**
* A document provider that knows about {@link DiffEditorInput}.
*/
private static class DiffDocumentProvider extends AbstractDocumentProvider {
@Override
public IStatus getStatus(Object element) {
if (element instanceof CommitEditorInput) {
RepositoryCommit commit = ((CommitEditorInput) element)
.getCommit();
if (commit != null && !commit.isStash()
&& commit.getRevCommit() != null
&& commit.getRevCommit().getParentCount() > 1) {
return Activator.createErrorStatus(
UIText.DiffEditorPage_WarningNoDiffForMerge);
}
}
return Status.OK_STATUS;
}
@Override
protected IDocument createDocument(Object element)
throws CoreException {
if (element instanceof DiffEditorInput) {
IDocument document = ((DiffEditorInput) element).getDocument();
if (document != null) {
return document;
}
}
return new Document();
}
@Override
protected IAnnotationModel createAnnotationModel(Object element)
throws CoreException {
return new AnnotationModel();
}
@Override
protected void doSaveDocument(IProgressMonitor monitor, Object element,
IDocument document, boolean overwrite) throws CoreException {
// Cannot save
}
@Override
protected IRunnableContext getOperationRunner(
IProgressMonitor monitor) {
return null;
}
}
/**
* An ephemeral {@link PreferenceStore} that sets the annotation colors
* based on the theme-dependent colors defined for the line backgrounds in a
* unified diff. This ensures that the annotation colors are always
* consistent with the line backgrounds. Plus the user doesn't have to
* configure several related colors, and the annotations update when the
* theme changes.
*/
private static class ThemePreferenceStore extends PreferenceStore {
// The colors defined in plugin.xml for these annotations are not taken
// into account. We always use auto-computed colors based on the current
// settings for the line background colors.
private static final String ADD_ANNOTATION_COLOR_PREFERENCE = "org.eclipse.egit.ui.commitEditor.diffAddedColor"; //$NON-NLS-1$
private static final String REMOVE_ANNOTATION_COLOR_PREFERENCE = "org.eclipse.egit.ui.commitEditor.diffRemovedColor"; //$NON-NLS-1$
private final IPropertyChangeListener listener = event -> {
String property = event.getProperty();
if (IThemeManager.CHANGE_CURRENT_THEME.equals(property)) {
setColorRegistry();
initColors();
} else if (THEME_DiffAddBackgroundColor.equals(property)
|| THEME_DiffRemoveBackgroundColor.equals(property)) {
initColors();
}
};
private ColorRegistry currentColors;
public ThemePreferenceStore() {
super();
setColorRegistry();
initColors();
PlatformUI.getWorkbench().getThemeManager()
.addPropertyChangeListener(listener);
}
private void setColorRegistry() {
if (currentColors != null) {
currentColors.removeListener(listener);
}
currentColors = PlatformUI.getWorkbench().getThemeManager()
.getCurrentTheme().getColorRegistry();
currentColors.addListener(listener);
}
private void initColors() {
// The overview ruler tones down colors. Since our background colors
// usually are already rather pale, let's saturate them more and
// brighten them, otherwise the annotations will be barely visible.
RGB rgb = adjust(currentColors.getRGB(THEME_DiffAddBackgroundColor),
4.0);
setValue(ADD_ANNOTATION_COLOR_PREFERENCE,
StringConverter.asString(rgb));
rgb = adjust(currentColors.getRGB(THEME_DiffRemoveBackgroundColor),
4.0);
setValue(REMOVE_ANNOTATION_COLOR_PREFERENCE,
StringConverter.asString(rgb));
}
/**
* Increases the saturation (simple multiplier), and brightens dark
* colors.
*
* @param rgb
* to modify
* @param saturation
* multiplier
* @return A new {@link RGB} for the new saturated and possibly
* brightened color
*/
private RGB adjust(RGB rgb, double saturation) {
float[] hsb = rgb.getHSB();
// We also brighten the color because otherwise the color
// manipulations in OverviewRuler result in a fill color barely
// discernible from a dark background.
return new RGB(hsb[0], (float) Math.min(hsb[1] * saturation, 1.0),
hsb[2] < 0.5 ? hsb[2] * 2 : hsb[2]);
}
public void dispose() {
PlatformUI.getWorkbench().getThemeManager()
.removePropertyChangeListener(listener);
if (currentColors != null) {
currentColors.removeListener(listener);
currentColors = null;
}
}
}
}