From 14cd5eb6834a7d3a4368611fc30c46b8959c2928 Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Wed, 1 Jul 2026 21:29:38 +0200 Subject: [PATCH] Fold unchanged regions in the unified diff Add a foldUnchanged(contextLines) option to UnifiedDiff that collapses the unchanged gaps between changes, keeping a few context lines around each change, similar to the unified view on GitHub. It reuses the editor's projection (folding) model, so it is a no-op when folding is disabled, and its folds are tagged so they can be removed without touching the editor's own folds. Each collapsed region shows a clickable "Expand n unchanged lines" code mining that expands it in place, drawn as a band across the editor so it reads as a break between two hunks. A "Fold unchanged regions" preference turns the folding on and off. It is on by default and shares a group with the existing unified diff preference on the Compare page. Only a complete set of code minings is reused. Collapsing a region can keep a mining from being created, and reusing what is left dropped that diff for good, because nothing recomputed it from the diffs afterwards. A line taken out in one place and put back further down lost the overlay of its old place that way. Folds of the editor whose first line a collapsed region hides are taken out of the projection model while that region is collapsed and put back when it is expanded again. The folding ruler paints such a fold on the first line of it that is still visible but toggles it by its start line, so its indicator would sit on a foreign line and clicking it would do nothing. --- .../compare/internal/CompareMessages.java | 2 + .../internal/CompareMessages.properties | 2 + .../internal/ComparePreferencePage.java | 13 +- .../compare/internal/CompareUIPlugin.java | 12 + .../compare/unifieddiff/UnifiedDiff.java | 17 +- .../UnifiedDiffCodeMiningProvider.java | 106 ++++- .../internal/UnifiedDiffManager.java | 352 +++++++++++++++- .../org.eclipse.compare/plugin.properties | 2 + .../team/tests/core/AllTeamUITests.java | 4 + .../tests/ui/UnifiedDiffFoldRegionsTest.java | 388 ++++++++++++++++++ .../ui/UnifiedDiffShadowedFoldsTest.java | 315 ++++++++++++++ 11 files changed, 1209 insertions(+), 4 deletions(-) create mode 100644 team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffFoldRegionsTest.java create mode 100644 team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffShadowedFoldsTest.java diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.java index 95e723ba9ea..45c61ac2585 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.java @@ -148,6 +148,8 @@ private CompareMessages() { public static String UnifiedDiff_openTwoWayCompare_tooltip; public static String UnifiedDiff_preparing; public static String UnifiedDiff_computing; + public static String UnifiedDiff_expandUnchangedLine; + public static String UnifiedDiff_expandUnchangedLines; static { NLS.initializeMessages(BUNDLE_NAME, CompareMessages.class); diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.properties b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.properties index fbcfa55082c..895a1b3df8d 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.properties +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareMessages.properties @@ -163,3 +163,5 @@ UnifiedDiff_revert=Revert UnifiedDiff_openTwoWayCompare_tooltip=Open in 2-way Compare Editor UnifiedDiff_preparing=Preparing Unified Diff for {0} UnifiedDiff_computing=Computing Unified Diff +UnifiedDiff_expandUnchangedLine=Expand 1 unchanged line +UnifiedDiff_expandUnchangedLines=Expand {0} unchanged lines diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/ComparePreferencePage.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/ComparePreferencePage.java index 1d6b3e1ca04..206be663768 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/ComparePreferencePage.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/ComparePreferencePage.java @@ -50,6 +50,7 @@ import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Group; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Text; import org.eclipse.ui.IWorkbench; @@ -124,6 +125,7 @@ private String loadPreviewContentFromFile(String key) { public static final String REMOVED_LINES_REGEX= PREFIX + "RemovedLinesRegex"; //$NON-NLS-1$ public static final String SWAPPED = PREFIX + "Swapped"; //$NON-NLS-1$ public static final String UNIFIED_DIFF = PREFIX + "UnifiedDiff"; //$NON-NLS-1$ + public static final String UNIFIED_DIFF_FOLD_UNCHANGED = PREFIX + "UnifiedDiffFoldUnchanged"; //$NON-NLS-1$ private IPropertyChangeListener fPreferenceChangeListener; @@ -156,6 +158,7 @@ private String loadPreviewContentFromFile(String key) { new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.STRING, ICompareUIConstants.PREF_NAVIGATION_END_ACTION_LOCAL), new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.BOOLEAN, SWAPPED), new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.BOOLEAN, UNIFIED_DIFF), + new OverlayPreferenceStore.OverlayKey(OverlayPreferenceStore.BOOLEAN, UNIFIED_DIFF_FOLD_UNCHANGED), }; private final List editors = new ArrayList<>(); private CTabItem fTextCompareTab; @@ -180,6 +183,7 @@ public static void initDefaults(IPreferenceStore store) { store.setDefault(ICompareUIConstants.PREF_NAVIGATION_END_ACTION_LOCAL, ICompareUIConstants.PREF_VALUE_LOOP); store.setDefault(SWAPPED, true); store.setDefault(UNIFIED_DIFF, false); + store.setDefault(UNIFIED_DIFF_FOLD_UNCHANGED, true); } public ComparePreferencePage() { @@ -289,7 +293,14 @@ private Control createGeneralPage(Composite parent) { addCheckBox(composite, "ComparePreferencePage.structureCompare.label", OPEN_STRUCTURE_COMPARE, 0); //$NON-NLS-1$ addCheckBox(composite, "ComparePreferencePage.structureOutline.label", USE_OUTLINE_VIEW, 0); //$NON-NLS-1$ addCheckBox(composite, "ComparePreferencePage.ignoreWhitespace.label", IGNORE_WHITESPACE, 0); //$NON-NLS-1$ - addCheckBox(composite, "ComparePreferencePage.unifiedDiff.label", UNIFIED_DIFF, 0); //$NON-NLS-1$ + Group unifiedDiffGroup= new Group(composite, SWT.NONE); + unifiedDiffGroup.setText(Utilities.getString("ComparePreferencePage.unifiedDiffGroup.label")); //$NON-NLS-1$ + unifiedDiffGroup.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + unifiedDiffGroup.setLayout(new GridLayout(1, false)); + + addCheckBox(unifiedDiffGroup, "ComparePreferencePage.unifiedDiff.label", UNIFIED_DIFF, 0); //$NON-NLS-1$ + addCheckBox(unifiedDiffGroup, "ComparePreferencePage.unifiedDiffFoldUnchanged.label", //$NON-NLS-1$ + UNIFIED_DIFF_FOLD_UNCHANGED, 0); // a spacer new Label(composite, SWT.NONE); diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareUIPlugin.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareUIPlugin.java index f3df9ab744a..308b8871954 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareUIPlugin.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareUIPlugin.java @@ -314,6 +314,10 @@ private static final class PrefetchedElement { public static final int NO_DIFFERENCE = 10000; + // Number of unchanged context lines kept around each change when the unified + // diff collapses unchanged regions. + private static final int UNIFIED_DIFF_CONTEXT_LINES = 3; + /** * The plugin singleton. */ @@ -633,6 +637,13 @@ public void openCompareEditor(final CompareEditorInput input, openClassicCompareEditor(input, page, editor, activate); } + /** The context lines to keep, or a negative value when folding is turned off. */ + private static int foldUnchangedContextLines() { + return getDefault().getPreferenceStore().getBoolean(ComparePreferencePage.UNIFIED_DIFF_FOLD_UNCHANGED) + ? UNIFIED_DIFF_CONTEXT_LINES + : -1; + } + /** Opens the classic side by side compare editor on the given input. */ private void openClassicCompareEditor(final CompareEditorInput input, final IWorkbenchPage page, final IReusableEditor editor, final boolean activate) { @@ -736,6 +747,7 @@ private boolean openUnifiedDiff(UnifiedDiffSource source, CompareEditorInput inp .tokenComparatorFactory(t -> mergerInput != null ? mergerInput.createTokenComparator(t) : null) .ignoreWhiteSpace(Utilities.getBoolean(input.getCompareConfiguration(), CompareConfiguration.IGNORE_WHITESPACE, false)) + .foldUnchanged(foldUnchangedContextLines()) .open(); // The user canceled the diff, not the open: leave the text editor alone // instead of falling back to the classic compare editor. diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiff.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiff.java index 4a187985132..b28d53110d1 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiff.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiff.java @@ -62,6 +62,7 @@ public static final class Builder { private List additionalActions; private TokenComparatorFactory tokenComparatorFactory; private IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory; + private int foldContextLines = -1; private Builder(ITextEditor editor, String source, UnifiedDiffMode mode) { this.editor = Objects.requireNonNull(editor, "Editor cannot be null"); //$NON-NLS-1$ @@ -89,9 +90,23 @@ public Builder ignoreWhiteSpace(boolean value) { return this; } + /** + * Collapses unchanged regions between diffs, keeping the given number of + * context lines (at least one) around each change. A negative value disables + * folding. + *

+ * The folding of the editor is reused, so this has no effect in an editor + * without folding support, such as the default text editor, or in an editor + * whose folding the user turned off. + */ + public Builder foldUnchanged(int contextLines) { + this.foldContextLines = contextLines; + return this; + } + public IStatus open() { return UnifiedDiffManager.open(editor, source, mode, additionalActions, tokenComparatorFactory, - ignoreWhitespaceContributorFactory, ignoreWhiteSpace); + ignoreWhitespaceContributorFactory, ignoreWhiteSpace, foldContextLines); } } } diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java index 143a2a68eec..96cf9da3824 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffCodeMiningProvider.java @@ -31,6 +31,7 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; +import org.eclipse.compare.internal.CompareMessages; import org.eclipse.compare.unifieddiff.UnifiedDiffMode; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; import org.eclipse.core.runtime.IProgressMonitor; @@ -55,6 +56,7 @@ import org.eclipse.jface.text.source.SourceViewer; import org.eclipse.jface.text.source.inlined.LineFooterAnnotation; import org.eclipse.jface.text.source.inlined.LineHeaderAnnotation; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.custom.StyledText; @@ -82,6 +84,7 @@ public class UnifiedDiffCodeMiningProvider extends AbstractCodeMiningProvider { private Color deletionBackgroundColor; private Color detailedDiffColor; + private Color foldSeparatorColor; private boolean lastIsOverlay; @Override @@ -95,6 +98,10 @@ public void dispose() { detailedDiffColor.dispose(); } detailedDiffColor = null; + if (foldSeparatorColor != null && !foldSeparatorColor.isDisposed()) { + foldSeparatorColor.dispose(); + } + foldSeparatorColor = null; } finally { super.dispose(); } @@ -126,8 +133,12 @@ public CompletableFuture> provideCodeMinings(ITextVi if (this.deletionBackgroundColor != null && !this.deletionBackgroundColor.isDisposed()) { this.deletionBackgroundColor.dispose(); } + if (this.foldSeparatorColor != null && !this.foldSeparatorColor.isDisposed()) { + this.foldSeparatorColor.dispose(); + } this.detailedDiffColor = new Color(interpolate(deletionColor, background, 0.9)); this.deletionBackgroundColor = new Color(interpolate(deletionColor, background, 0.8)); + this.foldSeparatorColor = new Color(separatorBackground(background)); lastIsOverlay = isOverlay; } if (viewer instanceof ISourceViewer sv && UnifiedDiffManager.get(viewer) != null) { @@ -161,7 +172,13 @@ public CompletableFuture> provideCodeMinings(ITextVi } } } - if (existingMinings.size() > 0) { + // Only the complete set may be reused. Collapsing a region can keep an + // annotation from being created, and reusing what is left would drop that + // diff for good, because nothing recomputes it from the diffs afterwards. + if (existingMinings.size() >= expectedMiningCount(diffs)) { + // the expander minings are recreated instead of reused so that they + // reflect the current expansion state of the folds + createFoldRegionCodeMinings(viewer, existingMinings); return CompletableFuture.completedFuture(existingMinings); } } @@ -170,9 +187,13 @@ public CompletableFuture> provideCodeMinings(ITextVi // take an immutable snapshot so the async iteration cannot observe // concurrent modifications when accept/hide actions mutate the live list List diffsSnapshot = List.copyOf(diffs); + // created on the calling thread because it reads the projection annotation model + List foldMinings = new ArrayList<>(); + createFoldRegionCodeMinings(viewer, foldMinings); return CompletableFuture.supplyAsync(() -> { List minings = new ArrayList<>(); createLineHeaderCodeMinings(diffsSnapshot, minings, viewer, tabWidth); + minings.addAll(foldMinings); return minings; }); } @@ -217,6 +238,17 @@ private int getTabWidth(ITextViewer viewer) { return tabWidth; } + /** How many minings {@link #createLineHeaderCodeMinings} would create for the diffs. */ + public static int expectedMiningCount(List diffs) { + int expected = 0; + for (UnifiedDiff diff : diffs) { + if (diff.mode.equals(UnifiedDiffMode.REPLACE_MODE) ? !diff.leftStr.isEmpty() : !diff.rightStr.isEmpty()) { + expected++; + } + } + return expected; + } + private void createLineHeaderCodeMinings(List diffs, List minings, ITextViewer tv, int tabWidth) { if (diffs == null) { @@ -279,6 +311,78 @@ private static boolean startsLine(IDocument doc, int offset) throws BadLocationE return doc.getLineOffset(doc.getLineOfOffset(offset)) == offset; } + /** + * Creates one clickable expander mining per collapsed unchanged-region fold, + * shown as e.g. "Expand 42 unchanged lines" above the fold's caption line. + */ + private void createFoldRegionCodeMinings(ITextViewer viewer, List minings) { + IDocument doc = viewer.getDocument(); + if (doc == null) { + return; + } + Map folds = UnifiedDiffManager.getCollapsedFoldRegions(viewer); + for (Map.Entry fold : folds.entrySet()) { + Position position = fold.getValue(); + try { + int firstLine = doc.getLineOfOffset(position.getOffset()); + int lastLine = position.getLength() > 0 + ? doc.getLineOfOffset(position.getOffset() + position.getLength() - 1) + : firstLine; + // the first line of the region stays visible as the fold's caption + int hiddenLines = lastLine - firstLine; + if (hiddenLines <= 0) { + continue; + } + minings.add(new FoldedRegionCodeMining(new Position(position.getOffset(), 1), this, viewer, + fold.getKey(), hiddenLines, this.foldSeparatorColor)); + } catch (BadLocationException e) { + error(e); + } + } + } + + /** + * A band that stands out from the surrounding text, so the collapsed region + * reads as a break between two hunks rather than as another line of the file. + */ + private static RGB separatorBackground(RGB background) { + boolean dark = background != null && (background.red + background.green + background.blue) / 3 < 128; + return interpolate(dark ? new RGB(255, 255, 255) : new RGB(0, 0, 0), background, 0.92); + } + + static class FoldedRegionCodeMining extends LineHeaderCodeMining { + + private final String expandLabel; + private final Color separatorColor; + + public FoldedRegionCodeMining(Position position, ICodeMiningProvider provider, ITextViewer viewer, + Annotation foldAnnotation, int hiddenLines, Color separatorColor) throws BadLocationException { + super(position, provider, e -> UnifiedDiffManager.expandFoldRegion(viewer, foldAnnotation)); + this.expandLabel = hiddenLines == 1 ? CompareMessages.UnifiedDiff_expandUnchangedLine + : NLS.bind(CompareMessages.UnifiedDiff_expandUnchangedLines, Integer.valueOf(hiddenLines)); + this.separatorColor = separatorColor; + } + + @Override + public String getLabel() { + return this.expandLabel; + } + + @Override + public Point draw(GC gc, StyledText textWidget, Color color, int x, int y) { + if (this.separatorColor == null || this.separatorColor.isDisposed()) { + return super.draw(gc, textWidget, color, x, y); + } + gc.setBackground(this.separatorColor); + gc.setForeground(textWidget.getForeground()); + gc.setFont(textWidget.getFont()); + // a first run only to learn how tall the band has to be + Point size = super.draw(gc, textWidget, color, x, y); + gc.fillRectangle(0, y, textWidget.getBounds().width, size.y); + return super.draw(gc, textWidget, color, x, y); + } + } + static class UnifiedDiffFooterCodeMining extends DocumentFooterCodeMining { private final String unifiedDiffLabel; private final Color deletionBackgroundColor; diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java index cb5a6012edd..2717a4fa246 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UnifiedDiffManager.java @@ -15,11 +15,15 @@ import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import java.util.stream.Stream; @@ -59,6 +63,7 @@ import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension4; import org.eclipse.jface.text.ITextViewer; import org.eclipse.jface.text.Position; import org.eclipse.jface.text.codemining.ICodeMining; @@ -72,6 +77,8 @@ import org.eclipse.jface.text.source.ISourceViewer; import org.eclipse.jface.text.source.ISourceViewerExtension5; import org.eclipse.jface.text.source.inlined.AbstractInlinedAnnotation; +import org.eclipse.jface.text.source.projection.ProjectionAnnotation; +import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel; import org.eclipse.jface.text.source.projection.ProjectionViewer; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; @@ -107,6 +114,8 @@ public class UnifiedDiffManager { private static final String CURRENT_SELECTED_UNIFIED_DIFF_ANNO_KEY = "CURRENT_SELECTED_UNIFIED_DIFF_ANNO_KEY"; //$NON-NLS-1$ private static final String UNDO_LISTENER_KEY = "UNIFIED_DIFF_UNDO_LISTENER_KEY"; //$NON-NLS-1$ private static final String UNIFIED_DIFF_ANNOTATION_MODEL_LISTENER_KEY = "UNIFIED_DIFF_ANNOTATION_MODEL_LISTENER_KEY"; //$NON-NLS-1$ + private static final String UNIFIED_DIFF_FOLD_LISTENER_KEY = "UNIFIED_DIFF_FOLD_LISTENER_KEY"; //$NON-NLS-1$ + private static final String UNIFIED_DIFF_SHADOWED_FOLDS_KEY = "UNIFIED_DIFF_SHADOWED_FOLDS_KEY"; //$NON-NLS-1$ private static final String ADDITION_ANNO_TYPE = "org.eclipse.compare.unifieddiff.internal.addition"; //$NON-NLS-1$ private static final String DELETION_ANNO_TYPE = "org.eclipse.compare.unifieddiff.internal.deletion"; //$NON-NLS-1$ private static final String DETAILED_ADDITION_ANNO_TYPE = "org.eclipse.compare.unifieddiff.internal.detailedAddition"; //$NON-NLS-1$ @@ -162,10 +171,12 @@ private static IAnnotationModel installAnnotationModel(ITextViewer viewer, IDocu public static IStatus open(ITextEditor editor, String source, UnifiedDiffMode mode, List additionalActions, TokenComparatorFactory tokenComparatorFactory, - IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory, boolean ignoreWhiteSpace) { + IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory, boolean ignoreWhiteSpace, + int foldContextLines) { ITextViewer viewer = editor.getAdapter(ITextViewer.class); if (viewer instanceof ProjectionViewer pv) { pv.doOperation(ProjectionViewer.EXPAND_ALL); + removeFoldAnnotations(pv); } IDocument leftDocument = editor.getDocumentProvider().getDocument(editor.getEditorInput()); IAnnotationModel editorModel = annotationModelOf(editor); @@ -262,6 +273,10 @@ public static IStatus open(ITextEditor editor, String source, UnifiedDiffMode mo addUndoListener(viewer, leftDocument, model); addAnnoModelChangeListener(viewer, model); + if (foldContextLines >= 0 && viewer instanceof ProjectionViewer pv) { + foldUnchangedRegions(pv, leftDocument, unifiedDiffs, mode, foldContextLines); + } + if (unifiedDiffs.size() > 0) { runAfterRepaintFinished(viewer.getTextWidget(), () -> { Annotation firstAnno = getFirstAnnotationForUnifiedDiff(model, unifiedDiffs.get(0)); @@ -271,6 +286,338 @@ public static IStatus open(ITextEditor editor, String source, UnifiedDiffMode mo return Status.OK_STATUS; } + /** + * Marker for the projection annotations added to collapse unchanged regions so + * they can be told apart from the editor's own (e.g. structural) folds. + */ + private static final class UnifiedDiffFoldAnnotation extends ProjectionAnnotation { + UnifiedDiffFoldAnnotation() { + super(true); // initially collapsed + } + } + + /** + * Collapses the unchanged regions between the displayed diffs, keeping + * {@code contextLines} visible next to each change. Reuses the editor's + * projection (folding) model, so this is a no-op when folding is disabled for + * the editor. Folds of the editor that a collapsed region would swallow are + * taken out of the model until that region is expanded again. + */ + public static void foldUnchangedRegions(ProjectionViewer viewer, IDocument document, + List unifiedDiffs, UnifiedDiffMode mode, int contextLines) { + ProjectionAnnotationModel projectionModel = viewer.getProjectionAnnotationModel(); + if (projectionModel == null) { + return; + } + Map foldsToAdd = new HashMap<>(); + for (Position region : unchangedFoldRegions(document, unifiedDiffs, mode, contextLines)) { + foldsToAdd.put(new UnifiedDiffFoldAnnotation(), region); + } + if (!foldsToAdd.isEmpty()) { + addFoldChangeListener(viewer); + projectionModel.replaceAnnotations(null, foldsToAdd); + syncShadowedEditorFolds(viewer); + } + } + + /** + * Returns the regions to collapse: the unchanged gaps before, between and after + * the given diffs, each shortened by {@code contextLines} towards a neighboring + * change. A gap that would not hide a line below its caption is left out. + */ + public static List unchangedFoldRegions(IDocument document, List unifiedDiffs, UnifiedDiffMode mode, + int contextLines) { + if (unifiedDiffs.isEmpty()) { + return List.of(); + } + // At least one context line so that the expander code mining and the code + // minings anchored to the first line after a change never share a line. + int context = Math.max(1, contextLines); + int lineCount = document.getNumberOfLines(); + // The gaps are walked front to back, so the diffs have to be in document order + // rather than in whatever order the caller collected them. + List diffs = new ArrayList<>(unifiedDiffs); + diffs.sort(Comparator.comparingInt(diff -> diff.leftStart)); + List regions = new ArrayList<>(); + try { + // The unchanged regions are the gaps in the document before, between and + // after the displayed diffs. + int gapStart = 0; // first line of the current unchanged gap + for (int i = 0; i <= diffs.size(); i++) { + boolean atStart = i == 0; + boolean atEnd = i == diffs.size(); + int gapEnd = atEnd ? lineCount : document.getLineOfOffset(diffs.get(i).leftStart); // exclusive + // Keep context lines next to an adjacent change; none is reserved at the + // file start or end because there is no neighboring change there. The first + // folded line stays visible as the fold's caption. + int foldFirst = gapStart + (atStart ? 0 : context); + int foldEnd = gapEnd - (atEnd ? 0 : context); // exclusive + if (foldEnd - foldFirst >= 2) { // at least one line is hidden below the caption + int offset = document.getLineOffset(foldFirst); + int end = foldEnd < lineCount ? document.getLineOffset(foldEnd) : document.getLength(); + // A file ending in a line delimiter has an empty last line that the + // region would reach without covering it, which leaves a fold that the + // expander code mining does not count and therefore cannot expand. + if (end > offset && document.getLineOfOffset(end - 1) > foldFirst) { + regions.add(new Position(offset, end - offset)); + } + } + if (!atEnd) { + UnifiedDiff diff = diffs.get(i); + // The displayed range has the same length as the diff annotation: in + // replace mode the document already contains the right content. + int length = UnifiedDiffMode.REPLACE_MODE.equals(mode) ? diff.rightLength : diff.leftLength; + int lastOffset = length > 0 ? diff.leftStart + length - 1 : diff.leftStart; + gapStart = Math.max(gapStart, + document.getLineOfOffset(Math.min(lastOffset, document.getLength())) + 1); + } + } + } catch (BadLocationException e) { + error(e); + return List.of(); + } + return regions; + } + + /** + * Removes the unchanged-region folds previously added by + * {@link #foldUnchangedRegions}, leaving the editor's own folds untouched. + */ + private static void removeFoldAnnotations(ProjectionViewer viewer) { + ProjectionAnnotationModel projectionModel = viewer.getProjectionAnnotationModel(); + if (projectionModel == null) { + return; + } + StyledText tw = viewer.getTextWidget(); + if (tw != null && !tw.isDisposed()) { + var listener = (IAnnotationModelListener) tw.getData(UNIFIED_DIFF_FOLD_LISTENER_KEY); + if (listener != null) { + tw.setData(UNIFIED_DIFF_FOLD_LISTENER_KEY, null); + projectionModel.removeAnnotationModelListener(listener); + } + } + List toRemove = new ArrayList<>(); + for (Iterator it = projectionModel.getAnnotationIterator(); it.hasNext();) { + Annotation annotation = it.next(); + if (annotation instanceof UnifiedDiffFoldAnnotation) { + toRemove.add(annotation); + } + } + if (!toRemove.isEmpty()) { + projectionModel.replaceAnnotations(toRemove.toArray(new Annotation[0]), null); + } + // nothing is collapsed any more, so every fold of the editor is usable again + syncShadowedEditorFolds(viewer); + if (!toRemove.isEmpty() && viewer instanceof ISourceViewerExtension5 ext) { + // the listener is already gone, so nothing else drops the expander minings + // of the regions just removed + ext.updateCodeMinings(); + } + } + + /** + * Whether a fold starting at the given position is swallowed by one of the + * collapsed regions. The folding ruler paints such a fold on the first line of + * it that is still visible but toggles it by its start line, so its indicator + * either sits on a foreign line where clicking it does nothing, or shares the + * caption line of the collapsed region and competes with it. + */ + public static boolean isShadowedByCollapsedRegion(Position fold, Collection collapsedRegions) { + for (Position region : collapsedRegions) { + if (region.getOffset() <= fold.getOffset() && fold.getOffset() < region.getOffset() + region.getLength()) { + return true; + } + } + return false; + } + + /** + * Takes the editor's own folds that a collapsed unchanged region swallowed out + * of the projection model and puts back those that became usable again, so that + * the folding ruler only ever offers indicators that do something. + */ + private static void syncShadowedEditorFolds(ProjectionViewer viewer) { + ProjectionAnnotationModel projectionModel = viewer.getProjectionAnnotationModel(); + StyledText tw = viewer.getTextWidget(); + if (projectionModel == null || tw == null || tw.isDisposed()) { + return; + } + IDocument document = viewer.getDocument(); + if (document == null) { + return; + } + long stamp = modificationStamp(document); + Collection collapsed = getCollapsedFoldRegions(viewer).values(); + Map shadowed = getShadowedEditorFolds(tw); + Set presentStarts = new HashSet<>(); + List toHide = new ArrayList<>(); + for (Iterator it = projectionModel.getAnnotationIterator(); it.hasNext();) { + Annotation annotation = it.next(); + if (annotation instanceof UnifiedDiffFoldAnnotation || !(annotation instanceof ProjectionAnnotation fold)) { + continue; + } + Position position = projectionModel.getPosition(annotation); + if (position == null || position.isDeleted()) { + continue; + } + presentStarts.add(Integer.valueOf(position.getOffset())); + // a collapsed fold hides itself entirely, so it has no indicator that could + // end up on a foreign line and taking it out would show its content again + if (!fold.isCollapsed() && isShadowedByCollapsedRegion(position, collapsed)) { + toHide.add(annotation); + shadowed.put(annotation, new ShadowedFold(position, stamp)); + } + } + Map toRestore = new HashMap<>(); + for (Iterator> it = shadowed.entrySet().iterator(); it.hasNext();) { + Map.Entry entry = it.next(); + ShadowedFold fold = entry.getValue(); + if (isShadowedByCollapsedRegion(fold.position(), collapsed)) { + continue; + } + it.remove(); + // out of the model the position no longer follows the document, so it only + // still describes the fold as long as nothing was edited; a fold the editor + // recreated in the meantime already occupies the indicator line + if (fold.modificationStamp() != stamp || fold.position().isDeleted() + || presentStarts.contains(Integer.valueOf(fold.position().getOffset()))) { + continue; + } + toRestore.put(entry.getKey(), fold.position()); + } + if (!toHide.isEmpty() || !toRestore.isEmpty()) { + projectionModel.replaceAnnotations(toHide.toArray(new Annotation[0]), toRestore); + } + } + + /** A fold of the editor taken out of the projection model, as it was then. */ + private record ShadowedFold(Position position, long modificationStamp) { + } + + private static long modificationStamp(IDocument document) { + return document instanceof IDocumentExtension4 ext ? ext.getModificationStamp() + : IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP; + } + + @SuppressWarnings("unchecked") + private static Map getShadowedEditorFolds(StyledText tw) { + Map folds = (Map) tw + .getData(UNIFIED_DIFF_SHADOWED_FOLDS_KEY); + if (folds == null) { + folds = new HashMap<>(); + tw.setData(UNIFIED_DIFF_SHADOWED_FOLDS_KEY, folds); + } + return folds; + } + + /** + * Returns the currently collapsed unchanged-region folds of the given viewer + * with their positions. + */ + static Map getCollapsedFoldRegions(ITextViewer viewer) { + Map result = new HashMap<>(); + if (viewer instanceof ProjectionViewer pv) { + ProjectionAnnotationModel projectionModel = pv.getProjectionAnnotationModel(); + if (projectionModel != null) { + for (Iterator it = projectionModel.getAnnotationIterator(); it.hasNext();) { + Annotation annotation = it.next(); + if (annotation instanceof UnifiedDiffFoldAnnotation fold && fold.isCollapsed()) { + Position position = projectionModel.getPosition(annotation); + if (position != null && !position.isDeleted()) { + result.put(annotation, position); + } + } + } + } + } + return result; + } + + /** + * Expands the given unchanged-region fold in the given viewer. + */ + static void expandFoldRegion(ITextViewer viewer, Annotation annotation) { + if (viewer instanceof ProjectionViewer pv) { + ProjectionAnnotationModel projectionModel = pv.getProjectionAnnotationModel(); + if (projectionModel != null) { + projectionModel.expand(annotation); + } + } + } + + /** + * Refreshes the code minings when unchanged-region folds are expanded or + * collapsed, so their inline expanders appear and disappear accordingly. + */ + private static void addFoldChangeListener(ProjectionViewer viewer) { + StyledText tw = viewer.getTextWidget(); + ProjectionAnnotationModel projectionModel = viewer.getProjectionAnnotationModel(); + if (tw == null || tw.isDisposed() || projectionModel == null + || tw.getData(UNIFIED_DIFF_FOLD_LISTENER_KEY) != null) { + return; + } + IAnnotationModelListener listener = new FoldChangeListener(viewer); + tw.setData(UNIFIED_DIFF_FOLD_LISTENER_KEY, listener); + projectionModel.addAnnotationModelListener(listener); + } + + private static final class FoldChangeListener + implements IAnnotationModelListener, IAnnotationModelListenerExtension { + + private final ProjectionViewer viewer; + + FoldChangeListener(ProjectionViewer viewer) { + this.viewer = viewer; + } + + @Override + public void modelChanged(AnnotationModelEvent event) { + // folds the editor contributes later, such as after a reconcile, have to be + // checked against the collapsed regions as well + if (!event.isWorldChange() && !concernsFolds(event, ProjectionAnnotation.class)) { + return; + } + StyledText tw = viewer.getTextWidget(); + if (tw == null || tw.isDisposed()) { + return; + } + boolean ownFolds = event.isWorldChange() || concernsFolds(event, UnifiedDiffFoldAnnotation.class); + // the model is in the middle of a change and may even notify from its own + // thread, so do the rest once that change is through and on the UI thread + tw.getDisplay().asyncExec(() -> { + if (tw.isDisposed()) { + return; + } + syncShadowedEditorFolds(viewer); + if (ownFolds && viewer instanceof ISourceViewerExtension5 ext) { + ext.updateCodeMinings(); + } + }); + } + + private static boolean concernsFolds(AnnotationModelEvent event, Class foldType) { + return concernsFolds(event.getAddedAnnotations(), foldType) + || concernsFolds(event.getRemovedAnnotations(), foldType) + || concernsFolds(event.getChangedAnnotations(), foldType); + } + + private static boolean concernsFolds(Annotation[] annotations, Class foldType) { + if (annotations != null) { + for (Annotation annotation : annotations) { + if (foldType.isInstance(annotation)) { + return true; + } + } + } + return false; + } + + @Override + public void modelChanged(IAnnotationModel model) { + // handled by the AnnotationModelEvent variant + } + } + static boolean isViewerInPart(IWorkbenchPart part, ITextViewer viewer) { if (part == null) { return false; @@ -1255,6 +1602,9 @@ static void disposeUnifiedDiff(ITextViewer tv, IAnnotationModel model, StyledTex // SWT removes listeners automatically when the widget is disposed return; } + if (tv instanceof ProjectionViewer pv) { + removeFoldAnnotations(pv); + } tw.getTypedListeners(SWT.MouseMove, UnifiedDiffMouseMoveListener.class) .forEach(tw::removeMouseMoveListener); tw.getTypedListeners(SWT.Paint, UnifiedDiffPaintListener.class) diff --git a/team/bundles/org.eclipse.compare/plugin.properties b/team/bundles/org.eclipse.compare/plugin.properties index 854c89aee88..d3fd778f89c 100644 --- a/team/bundles/org.eclipse.compare/plugin.properties +++ b/team/bundles/org.eclipse.compare/plugin.properties @@ -126,7 +126,9 @@ ComparePreferencePage.structureCompare.label= &Open structure compare automatica ComparePreferencePage.structureOutline.label= Show structure compare in Outline &view when possible ComparePreferencePage.ignoreWhitespace.label= Ignore &white space ComparePreferencePage.saveBeforePatching.label= A&utomatically save editors with unsaved changes before browsing patches +ComparePreferencePage.unifiedDiffGroup.label=Unified Diff ComparePreferencePage.unifiedDiff.label=EXPERIMENTAL: Use Unified Diff instead of 2-way compare when possible +ComparePreferencePage.unifiedDiffFoldUnchanged.label=&Fold unchanged regions, keeping a few lines of context around each change ComparePreferencePage.regex.description=Enter regular expressions used to identify added or removed lines in a patch\n(e.g. '^\\+\\s*\\S' for an added line with at least one word character). ComparePreferencePage.regexAdded.label=Added lines diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/core/AllTeamUITests.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/core/AllTeamUITests.java index ea1f2a5beee..1dd5fbe7b83 100644 --- a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/core/AllTeamUITests.java +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/core/AllTeamUITests.java @@ -15,7 +15,9 @@ import org.eclipse.team.tests.core.mapping.AllTeamMappingTests; import org.eclipse.team.tests.ui.SaveableCompareEditorInputTest; +import org.eclipse.team.tests.ui.UnifiedDiffFoldRegionsTest; import org.eclipse.team.tests.ui.UnifiedDiffManagerTest; +import org.eclipse.team.tests.ui.UnifiedDiffShadowedFoldsTest; import org.eclipse.team.tests.ui.UnifiedDiffTextTest; import org.eclipse.team.tests.ui.synchronize.AllTeamSynchronizeTests; import org.junit.platform.suite.api.SelectClasses; @@ -26,7 +28,9 @@ AllTeamMappingTests.class, // AllTeamSynchronizeTests.class, // SaveableCompareEditorInputTest.class, // + UnifiedDiffFoldRegionsTest.class, // UnifiedDiffManagerTest.class, // + UnifiedDiffShadowedFoldsTest.class, // UnifiedDiffTextTest.class, // }) public class AllTeamUITests { diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffFoldRegionsTest.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffFoldRegionsTest.java new file mode 100644 index 00000000000..22cf8e91ea4 --- /dev/null +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffFoldRegionsTest.java @@ -0,0 +1,388 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Eclipse contributors - initial API and implementation + *******************************************************************************/ +package org.eclipse.team.tests.ui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.compare.unifieddiff.UnifiedDiffMode; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.Position; +import org.junit.jupiter.api.Test; + +/** + * Tests which unchanged regions the unified diff collapses. The assertions are + * expressed in the lines the user would no longer see: a collapsed region keeps + * its first line visible as the caption and hides the rest. + */ +@SuppressWarnings("restriction") +public class UnifiedDiffFoldRegionsTest { + + /** + * The bulk of an unchanged file has to be folded away while the change and the + * requested context around it stay visible. + */ + @Test + public void testContextLinesAroundAChangeStayVisible() throws Exception { + IDocument document = numberedLines(40); + + Set hidden = hiddenLines(document, 3, changeOnLine(document, 20)); + + assertThat(hidden).as("the lines far from the change must be folded away").isNotEmpty(); + assertVisible(hidden, 20, "the changed line"); + for (int line = 17; line <= 23; line++) { + assertVisible(hidden, line, "context line"); + } + assertTrue(hidden.contains(Integer.valueOf(5)), "line 5 is far from the change and must be hidden"); + assertTrue(hidden.contains(Integer.valueOf(34)), "line 34 is far from the change and must be hidden"); + } + + /** + * The context is what the caller asked for, not a fixed amount: a larger context + * has to keep strictly more lines visible. + */ + @Test + public void testALargerContextKeepsMoreLinesVisible() throws Exception { + IDocument document = numberedLines(40); + + Set withOne = hiddenLines(document, 1, changeOnLine(document, 20)); + Set withFive = hiddenLines(document, 5, changeOnLine(document, 20)); + + assertThat(withFive).as("a larger context hides fewer lines").isSubsetOf(withOne); + assertThat(withOne).as("a larger context hides fewer lines").isNotEqualTo(withFive); + for (int line = 15; line <= 25; line++) { + assertVisible(withFive, line, "context line"); + } + } + + /** + * Two changes must each keep their own context, and the gap between them has to + * be folded. A single fold across both would hide a change. + */ + @Test + public void testEveryChangeKeepsItsOwnContext() throws Exception { + IDocument document = numberedLines(60); + + Set hidden = hiddenLines(document, 2, changeOnLine(document, 10), changeOnLine(document, 45)); + + for (int changedLine : new int[] { 10, 45 }) { + assertVisible(hidden, changedLine, "changed line"); + for (int line = changedLine - 2; line <= changedLine + 2; line++) { + assertVisible(hidden, line, "context line"); + } + } + assertTrue(hidden.contains(Integer.valueOf(28)), "the gap between both changes must be folded"); + assertTrue(hidden.contains(Integer.valueOf(2)), "the region before the first change must be folded"); + assertTrue(hidden.contains(Integer.valueOf(55)), "the region after the last change must be folded"); + } + + /** + * A change on the very first line has no region above it, so no context must be + * reserved there and the fold below it still has to appear. + */ + @Test + public void testChangeOnTheFirstLine() throws Exception { + IDocument document = numberedLines(30); + + Set hidden = hiddenLines(document, 3, changeOnLine(document, 0)); + + assertVisible(hidden, 0, "the changed first line"); + for (int line = 1; line <= 3; line++) { + assertVisible(hidden, line, "context line"); + } + assertTrue(hidden.contains(Integer.valueOf(20)), "the unchanged rest of the file must be folded"); + } + + /** + * A region that would not hide a single line below its caption costs a fold + * marker and a click without saving anything, so it must not be created. + */ + @Test + public void testNoFoldWhenNothingWouldBeHidden() throws Exception { + IDocument document = numberedLines(7); + + List regions = UnifiedDiffManager.unchangedFoldRegions(document, + List.of(changeOnLine(document, 3)), UnifiedDiffMode.OVERLAY_READ_ONLY_MODE, 3); + + assertThat(regions).as("the context covers the whole file, so there is nothing to fold").isEmpty(); + } + + /** Without a change there is nothing to fold around, so nothing is collapsed. */ + @Test + public void testNoDiffsMeansNoFolds() { + List regions = UnifiedDiffManager.unchangedFoldRegions(numberedLines(40), List.of(), + UnifiedDiffMode.OVERLAY_READ_ONLY_MODE, 3); + + assertThat(regions).isEmpty(); + } + + /** + * At least one context line is kept even when none was asked for, so that the + * expander of a fold and the code mining of the following change cannot end up + * on the same line. + */ + @Test + public void testAtLeastOneContextLineIsKept() throws Exception { + IDocument document = numberedLines(40); + + Set hidden = hiddenLines(document, 0, changeOnLine(document, 20)); + + assertVisible(hidden, 19, "the line above the change"); + assertVisible(hidden, 21, "the line below the change"); + } + + /** + * The gaps are walked front to back, so the result must not depend on the order + * in which the caller collected the changes. + */ + @Test + public void testResultDoesNotDependOnTheOrderOfTheDiffs() throws Exception { + IDocument document = numberedLines(60); + UnifiedDiff first = changeOnLine(document, 10); + UnifiedDiff second = changeOnLine(document, 45); + + Set inOrder = hiddenLines(document, 2, first, second); + Set reversed = hiddenLines(document, 2, second, first); + + assertEquals(inOrder, reversed, "reversing the diffs must not change which lines are folded"); + } + + /** No fold may cover a changed line, whatever the context and the file size. */ + @Test + public void testFoldsNeverHideAChange() throws Exception { + IDocument document = numberedLines(200); + int[] changedLines = { 0, 7, 8, 50, 120, 121, 199 }; + List diffs = new ArrayList<>(); + for (int line : changedLines) { + diffs.add(changeOnLine(document, line)); + } + + for (int context = 0; context <= 4; context++) { + Set hidden = hiddenLines(document, context, diffs.toArray(new UnifiedDiff[0])); + for (int line : changedLines) { + assertVisible(hidden, line, "changed line with context " + context + ":"); + } + } + } + + /** + * In replace mode the document already holds the new content, so the folds have + * to give way to the lines the replacement occupies, not to the ones it + * replaced. + */ + @Test + public void testReplaceModeMeasuresTheChangeByItsNewContent() throws Exception { + IDocument document = numberedLines(60); + // one line of the left side is shown as three lines of the right side + UnifiedDiff diff = change(document, 20, 1, 3); + + Set inReplaceMode = hiddenLines(document, UnifiedDiffMode.REPLACE_MODE, 2, diff); + Set inOverlayMode = hiddenLines(document, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE, 2, diff); + + for (int line = 20; line <= 24; line++) { + assertVisible(inReplaceMode, line, "the replacement and its context line"); + } + assertTrue(inOverlayMode.contains(Integer.valueOf(24)), + "the overlay only occupies the line it is anchored to, so line 24 is foldable there"); + } + + /** + * An addition at the very end of a file that does not end with a newline sits + * on the last offset of the document. The unchanged lines above it still have + * to be folded. + */ + @Test + public void testAnAdditionAtTheEndOfAFileWithoutATrailingNewline() throws Exception { + IDocument document = new Document(numberedLines(40).get().stripTrailing()); + int end = document.getLength(); + UnifiedDiff addition = new UnifiedDiff(document, end, end, "", document, end, end + 6, "added\n", + new ArrayList<>(), UnifiedDiffMode.OVERLAY_READ_ONLY_MODE); + + Set hidden = hiddenLines(document, 3, addition); + + assertTrue(hidden.contains(Integer.valueOf(10)), "the unchanged lines above the addition must be folded"); + } + + /** + * A file ending with a newline has an empty last line. A change close to it + * must not leave a fold behind that only reaches that line, because the + * expander code mining does not count it and could not expand it again. + */ + @Test + public void testNoFoldForTheEmptyLineOfAFileEndingWithANewline() throws Exception { + IDocument document = numberedLines(40); + + Set hidden = hiddenLines(document, 1, changeOnLine(document, 37)); + + assertVisible(hidden, 39, "the last line with content"); + } + + /** + * A deletion is not shown by a code mining but by the annotation on the lines + * it covers, so those lines have to stay visible. + */ + @Test + public void testAPureDeletionStaysVisible() throws Exception { + IDocument document = numberedLines(60); + + Set hidden = hiddenLines(document, 3, deletionOfLines(document, 30, 2)); + + assertVisible(hidden, 30, "the first deleted line"); + assertVisible(hidden, 31, "the second deleted line"); + } + + /** + * A pure addition occupies no line of the document. Its code mining is anchored + * to the line it is inserted in front of, which therefore has to stay visible. + */ + @Test + public void testAPureAdditionInTheMiddleOfAFileStaysVisible() throws Exception { + IDocument document = numberedLines(60); + + Set hidden = hiddenLines(document, 3, additionBeforeLine(document, 30, "added\n")); + + assertVisible(hidden, 30, "the line the addition is anchored to"); + } + + /** + * A line deleted in one place and added again further down produces a deletion + * and a separate addition. Both ends of the move have to stay visible. + */ + @Test + public void testALineDeletedHereAndAddedLaterIsVisibleAtBothEnds() throws Exception { + IDocument document = numberedLines(60); + UnifiedDiff deletedHere = deletionOfLines(document, 10, 1); + UnifiedDiff addedThere = additionBeforeLine(document, 40, "line 10\n"); + + for (int context = 0; context <= 4; context++) { + Set hidden = hiddenLines(document, context, deletedHere, addedThere); + assertVisible(hidden, 10, "the line that was deleted, with context " + context + ":"); + assertVisible(hidden, 40, "the line the deleted text was added in front of, with context " + context + ":"); + } + } + + /** The same move, with the addition above the deletion in the document. */ + @Test + public void testALineDeletedHereAndAddedEarlierIsVisibleAtBothEnds() throws Exception { + IDocument document = numberedLines(60); + UnifiedDiff addedThere = additionBeforeLine(document, 10, "line 40\n"); + UnifiedDiff deletedHere = deletionOfLines(document, 40, 1); + + Set hidden = hiddenLines(document, 3, addedThere, deletedHere); + + assertVisible(hidden, 10, "the line the deleted text was added in front of"); + assertVisible(hidden, 40, "the line that was deleted"); + } + + /** + * A reused set of code minings is only complete when it holds one mining per + * diff that shows content of the other side. Reusing fewer would drop a diff + * for good, because nothing recomputes it from the diffs afterwards. + */ + @Test + public void testOnlyDiffsShowingTheOtherSideNeedACodeMining() throws Exception { + IDocument document = numberedLines(60); + UnifiedDiff shownAsOverlay = additionBeforeLine(document, 10, "line 40\n"); + UnifiedDiff shownOnItsOwnLines = deletionOfLines(document, 40, 1); + + assertEquals(1, UnifiedDiffCodeMiningProvider + .expectedMiningCount(List.of(shownAsOverlay, shownOnItsOwnLines)), + "only the overlay needs a mining, the deletion is drawn on the lines it covers"); + assertEquals(2, UnifiedDiffCodeMiningProvider + .expectedMiningCount(List.of(shownAsOverlay, changeOnLine(document, 50))), + "a replacement shows the other side as an overlay too"); + } + + // ------------------------------------------------------------------ helpers + + /** + * The lines that the given folds would hide. A collapsed region keeps its first + * line visible as the caption. + */ + private static Set hiddenLines(IDocument document, int contextLines, UnifiedDiff... diffs) + throws BadLocationException { + return hiddenLines(document, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE, contextLines, diffs); + } + + private static Set hiddenLines(IDocument document, UnifiedDiffMode mode, int contextLines, + UnifiedDiff... diffs) throws BadLocationException { + List regions = UnifiedDiffManager.unchangedFoldRegions(document, Arrays.asList(diffs), mode, + contextLines); + Set hidden = new LinkedHashSet<>(); + for (Position region : regions) { + int firstLine = document.getLineOfOffset(region.getOffset()); + int lastLine = document.getLineOfOffset(region.getOffset() + region.getLength() - 1); + assertTrue(lastLine > firstLine, "a fold that hides nothing must not be created"); + for (int line = firstLine + 1; line <= lastLine; line++) { + assertTrue(hidden.add(Integer.valueOf(line)), "folds must not overlap on line " + line); + } + } + return hidden; + } + + private static void assertVisible(Set hidden, int line, String what) { + assertThat(hidden).as(what + " " + line + " must stay visible").doesNotContain(Integer.valueOf(line)); + } + + /** A one line change of the given document line, as the manager records it. */ + private static UnifiedDiff changeOnLine(IDocument document, int line) throws BadLocationException { + return change(document, line, 1, 1); + } + + /** A deletion: the document holds the lines, the other side does not. */ + private static UnifiedDiff deletionOfLines(IDocument document, int line, int lines) throws BadLocationException { + int offset = document.getLineOffset(line); + int end = document.getLineOffset(line + lines); + return new UnifiedDiff(document, offset, end, document.get(offset, end - offset), document, offset, offset, "", + new ArrayList<>(), UnifiedDiffMode.OVERLAY_READ_ONLY_MODE); + } + + /** An addition: the other side holds a line the document does not. */ + private static UnifiedDiff additionBeforeLine(IDocument document, int line, String added) + throws BadLocationException { + int offset = document.getLineOffset(line); + return new UnifiedDiff(document, offset, offset, "", document, offset, offset + added.length(), added, + new ArrayList<>(), UnifiedDiffMode.OVERLAY_READ_ONLY_MODE); + } + + /** A change of {@code leftLines} shown as {@code rightLines}. */ + private static UnifiedDiff change(IDocument document, int line, int leftLines, int rightLines) + throws BadLocationException { + int offset = document.getLineOffset(line); + int leftEnd = document.getLineOffset(line + leftLines); + int rightEnd = document.getLineOffset(line + rightLines); + return new UnifiedDiff(document, offset, leftEnd, document.get(offset, leftEnd - offset), document, offset, + rightEnd, "changed\n", new ArrayList<>(), UnifiedDiffMode.OVERLAY_READ_ONLY_MODE); + } + + private static IDocument numberedLines(int count) { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < count; i++) { + content.append("line ").append(i).append('\n'); + } + return new Document(content.toString()); + } +} diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffShadowedFoldsTest.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffShadowedFoldsTest.java new file mode 100644 index 00000000000..d61b2972b7b --- /dev/null +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffShadowedFoldsTest.java @@ -0,0 +1,315 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Eclipse contributors - initial API and implementation + *******************************************************************************/ +package org.eclipse.team.tests.ui; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.eclipse.compare.unifieddiff.UnifiedDiffMode; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.Position; +import org.eclipse.jface.text.source.Annotation; +import org.eclipse.jface.text.source.AnnotationModel; +import org.eclipse.jface.text.source.projection.ProjectionAnnotation; +import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel; +import org.eclipse.jface.text.source.projection.ProjectionViewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests that the unchanged-region folds of the unified diff do not leave the + * folding ruler with fold indicators that cannot do anything: the folding ruler + * paints a fold whose first line is hidden on the first line of it that is still + * visible, but toggles it by its start line, so clicking such an indicator has + * no effect. + */ +@SuppressWarnings("restriction") +public class UnifiedDiffShadowedFoldsTest { + + private static final UnifiedDiffMode MODE = UnifiedDiffMode.OVERLAY_READ_ONLY_MODE; + + private Display display; + private Shell shell; + private ProjectionViewer viewer; + + @BeforeEach + public void setUp() { + display = Display.getDefault(); + assertNotNull(display, "the test needs a display"); + shell = new Shell(display); + } + + @AfterEach + public void tearDown() { + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + } + + /** + * The case reported on the pull request: a fold of the editor that starts inside + * a collapsed region and reaches out of it. Its indicator would be painted on a + * line the folding ruler does not associate with it, so it has to be taken out + * of the model while the region is collapsed. + */ + @Test + public void testAFoldReachingOutOfACollapsedRegionIsTakenOut() throws Exception { + IDocument document = numberedLines(100); + ProjectionAnnotationModel projectionModel = openViewer(document); + ProjectionAnnotation foldOfTheEditor = addFoldOfTheEditor(projectionModel, document, 10, 70); + + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + + assertFalse(isInModel(projectionModel, foldOfTheEditor), + "a fold starting on a hidden line must not keep an indicator that does nothing"); + } + + /** Expanding the region has to give the fold of the editor back to the user. */ + @Test + public void testAFoldComesBackWhenTheRegionIsExpanded() throws Exception { + IDocument document = numberedLines(100); + ProjectionAnnotationModel projectionModel = openViewer(document); + ProjectionAnnotation foldOfTheEditor = addFoldOfTheEditor(projectionModel, document, 10, 70); + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + assertFalse(isInModel(projectionModel, foldOfTheEditor), "the fold has to be taken out first"); + + viewer.doOperation(ProjectionViewer.EXPAND_ALL); + waitForPendingWork(); + + assertTrue(isInModel(projectionModel, foldOfTheEditor), + "the fold of the editor must be usable again once nothing hides its first line"); + } + + /** + * A fold whose first line stays visible works as it always did and must be left + * alone. + */ + @Test + public void testAFoldStartingOnAVisibleLineIsKept() throws Exception { + IDocument document = numberedLines(100); + ProjectionAnnotationModel projectionModel = openViewer(document); + // line 58 is one of the context lines kept visible above the change + ProjectionAnnotation foldOfTheEditor = addFoldOfTheEditor(projectionModel, document, 58, 90); + + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + + assertTrue(isInModel(projectionModel, foldOfTheEditor), "a fold the user can still click must be kept"); + } + + /** + * A collapsed fold of the editor hides itself completely, so it has no + * indicator on a foreign line. Taking it out would show its content again. + */ + @Test + public void testACollapsedFoldOfTheEditorIsLeftAlone() throws Exception { + IDocument document = numberedLines(100); + ProjectionAnnotationModel projectionModel = openViewer(document); + ProjectionAnnotation foldOfTheEditor = new ProjectionAnnotation(true); + projectionModel.replaceAnnotations(null, Map.of(foldOfTheEditor, lines(document, 10, 70))); + + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + + assertTrue(isInModel(projectionModel, foldOfTheEditor), + "taking a collapsed fold out would unfold what the user folded"); + } + + /** + * The editor contributes its folds asynchronously, so a fold arriving after the + * regions are collapsed has to be taken out as well. + */ + @Test + public void testAFoldContributedAfterTheRegionsAreCollapsedIsTakenOut() throws Exception { + IDocument document = numberedLines(100); + ProjectionAnnotationModel projectionModel = openViewer(document); + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + waitForPendingWork(); + + ProjectionAnnotation foldOfTheEditor = addFoldOfTheEditor(projectionModel, document, 10, 70); + waitForPendingWork(); + + assertFalse(isInModel(projectionModel, foldOfTheEditor), + "a fold the editor adds later must not keep an indicator that does nothing"); + } + + /** + * Out of the projection model a position no longer follows the document, so a + * fold taken out before an edit must not be put back at what it described then. + */ + @Test + public void testAFoldIsNotPutBackAfterTheDocumentWasEdited() throws Exception { + IDocument document = numberedLines(100); + ProjectionAnnotationModel projectionModel = openViewer(document); + ProjectionAnnotation foldOfTheEditor = addFoldOfTheEditor(projectionModel, document, 10, 70); + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + + document.replace(document.getLineOffset(59), 0, "inserted\n"); + viewer.doOperation(ProjectionViewer.EXPAND_ALL); + waitForPendingWork(); + + assertFalse(isInModel(projectionModel, foldOfTheEditor), + "a stale fold would put its indicator on the wrong line"); + } + + /** The collapsed region really has to hide its lines, not just be recorded. */ + @Test + public void testTheCollapsedRegionHidesItsLines() throws Exception { + IDocument document = numberedLines(100); + openViewer(document); + + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(changeOnLine(document, 60)), MODE, 3); + + String visible = viewer.getTextWidget().getText(); + assertFalse(visible.contains("line 30"), "a line far from the change must not be shown"); + assertTrue(visible.contains("line 60"), "the changed line must be shown"); + } + + /** + * A line deleted in one place and added again further down: the deletion is + * shown on the lines it covers and the addition on the line it is anchored to, + * so the collapsed regions have to leave both of them on screen. + */ + @Test + public void testAMovedLineIsShownAtBothEndsWhenCollapsed() throws Exception { + IDocument document = numberedLines(100); + openViewer(document); + UnifiedDiff deletedHere = deletionOfLine(document, 20); + UnifiedDiff addedThere = additionBeforeLine(document, 70, "line 20\n"); + + UnifiedDiffManager.foldUnchangedRegions(viewer, document, List.of(deletedHere, addedThere), MODE, 3); + + String visible = viewer.getTextWidget().getText(); + assertTrue(visible.contains("line 20"), "the deleted line must be shown"); + assertTrue(visible.contains("line 70"), "the line the addition is anchored to must be shown"); + assertFalse(visible.contains("line 45"), "a line far from both ends of the move must not be shown"); + } + + /** A fold starting on the caption line of a region competes with that region. */ + @Test + public void testAFoldOnTheCaptionLineOfARegionIsShadowed() throws Exception { + IDocument document = numberedLines(100); + List collapsed = collapsedRegions(document, 3, changeOnLine(document, 60)); + Position captionLineFold = new Position(collapsed.get(0).getOffset(), document.getLineLength(0)); + + assertTrue(UnifiedDiffManager.isShadowedByCollapsedRegion(captionLineFold, collapsed), + "two indicators on one line would toggle whichever the ruler happens to find first"); + } + + /** A fold below the last collapsed region is untouched. */ + @Test + public void testAFoldOutsideEveryRegionIsNotShadowed() throws Exception { + IDocument document = numberedLines(100); + List collapsed = collapsedRegions(document, 3, changeOnLine(document, 60)); + + assertFalse(UnifiedDiffManager.isShadowedByCollapsedRegion(lines(document, 58, 62), collapsed), + "a fold starting in the visible context around a change must be left alone"); + } + + /** Without a collapsed region nothing is shadowed. */ + @Test + public void testNothingIsShadowedWithoutCollapsedRegions() throws Exception { + IDocument document = numberedLines(100); + + assertFalse(UnifiedDiffManager.isShadowedByCollapsedRegion(lines(document, 10, 70), List.of())); + } + + // ------------------------------------------------------------------ helpers + + private ProjectionAnnotationModel openViewer(IDocument document) { + viewer = new ProjectionViewer(shell, null, null, false, SWT.V_SCROLL); + viewer.setDocument(document, new AnnotationModel()); + viewer.enableProjection(); + ProjectionAnnotationModel projectionModel = viewer.getProjectionAnnotationModel(); + assertNotNull(projectionModel, "the viewer must provide a projection model"); + return projectionModel; + } + + private static ProjectionAnnotation addFoldOfTheEditor(ProjectionAnnotationModel projectionModel, + IDocument document, int firstLine, int lastLine) throws BadLocationException { + ProjectionAnnotation fold = new ProjectionAnnotation(); + projectionModel.replaceAnnotations(null, Map.of(fold, lines(document, firstLine, lastLine))); + return fold; + } + + private static List collapsedRegions(IDocument document, int contextLines, UnifiedDiff... diffs) { + return UnifiedDiffManager.unchangedFoldRegions(document, List.of(diffs), MODE, contextLines); + } + + private static boolean isInModel(ProjectionAnnotationModel projectionModel, Annotation annotation) { + for (Iterator it = projectionModel.getAnnotationIterator(); it.hasNext();) { + if (it.next() == annotation) { + return true; + } + } + return false; + } + + /** Folds are put back from a runnable posted to the display. */ + private void waitForPendingWork() { + while (display.readAndDispatch()) { + // keep going until the queue is empty + } + } + + private static Position lines(IDocument document, int firstLine, int lastLine) throws BadLocationException { + int offset = document.getLineOffset(firstLine); + int end = document.getLineOffset(lastLine) + document.getLineLength(lastLine); + return new Position(offset, end - offset); + } + + /** A one line change of the given document line, as the manager records it. */ + private static UnifiedDiff changeOnLine(IDocument document, int line) throws BadLocationException { + int offset = document.getLineOffset(line); + int length = document.getLineLength(line); + return new UnifiedDiff(document, offset, offset + length, document.get(offset, length), document, offset, + offset + length, "changed\n", new ArrayList<>(), MODE); + } + + /** A deletion: the document holds the line, the other side does not. */ + private static UnifiedDiff deletionOfLine(IDocument document, int line) throws BadLocationException { + int offset = document.getLineOffset(line); + int length = document.getLineLength(line); + return new UnifiedDiff(document, offset, offset + length, document.get(offset, length), document, offset, + offset, "", new ArrayList<>(), MODE); + } + + /** An addition: the other side holds a line the document does not. */ + private static UnifiedDiff additionBeforeLine(IDocument document, int line, String added) + throws BadLocationException { + int offset = document.getLineOffset(line); + return new UnifiedDiff(document, offset, offset, "", document, offset, offset + added.length(), added, + new ArrayList<>(), MODE); + } + + private static IDocument numberedLines(int count) { + StringBuilder content = new StringBuilder(); + for (int i = 0; i < count; i++) { + content.append("line ").append(i).append('\n'); + } + return new Document(content.toString()); + } +}