From 0c90f6ef4fdccb58f87ae850f2d57b90688bc1e7 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. Enabled with three context lines on both unified diff paths. --- .../compare/internal/CompareMessages.java | 2 + .../internal/CompareMessages.properties | 2 + .../compare/internal/CompareUIPlugin.java | 5 + .../compare/unifieddiff/UnifiedDiff.java | 13 +- .../UnifiedDiffCodeMiningProvider.java | 56 +++++ .../internal/UnifiedDiffManager.java | 224 ++++++++++++++++- .../team/tests/core/AllTeamUITests.java | 2 + .../tests/ui/UnifiedDiffFoldRegionsTest.java | 231 ++++++++++++++++++ 8 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffFoldRegionsTest.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/CompareUIPlugin.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/CompareUIPlugin.java index 52ef72f1ba1..2c33a5db76a 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 @@ -287,6 +287,10 @@ private static final class SniffedElement { 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. */ @@ -697,6 +701,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(UNIFIED_DIFF_CONTEXT_LINES) .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..bb32bae9376 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,19 @@ 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. + */ + 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 840781bce82..e6b7332c298 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; @@ -162,6 +164,9 @@ public CompletableFuture> provideCodeMinings(ITextVi } } if (existingMinings.size() > 0) { + // 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 +175,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; }); } @@ -280,6 +289,53 @@ private void createLineHeaderCodeMinings(List diffs, 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)); + } catch (BadLocationException e) { + error(e); + } + } + } + + static class FoldedRegionCodeMining extends LineHeaderCodeMining { + + private final String expandLabel; + + public FoldedRegionCodeMining(Position position, ICodeMiningProvider provider, ITextViewer viewer, + Annotation foldAnnotation, int hiddenLines) 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)); + } + + @Override + public String getLabel() { + return this.expandLabel; + } + } + 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 3232fc0036a..7c5803e12cb 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,6 +15,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -70,6 +71,8 @@ import org.eclipse.jface.text.source.IAnnotationModelListenerExtension; 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; @@ -105,6 +108,7 @@ 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 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$ @@ -133,10 +137,12 @@ public static List get(ITextViewer viewer) { 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); } IAnnotationModel model = editor.getDocumentProvider().getAnnotationModel(editor.getEditorInput()); if (model == null) { @@ -232,6 +238,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)); @@ -241,6 +251,215 @@ 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. + */ + private 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); + } + } + + /** + * 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(); + if (end > offset) { + 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); + } + } + + /** + * 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) { + if (!concernsFolds(event.getAddedAnnotations()) && !concernsFolds(event.getRemovedAnnotations()) + && !concernsFolds(event.getChangedAnnotations())) { + return; + } + StyledText tw = viewer.getTextWidget(); + if (tw == null || tw.isDisposed()) { + return; + } + if (viewer instanceof ISourceViewerExtension5 ext) { + ext.updateCodeMinings(); + } + } + + private static boolean concernsFolds(Annotation[] annotations) { + if (annotations != null) { + for (Annotation annotation : annotations) { + if (annotation instanceof UnifiedDiffFoldAnnotation) { + 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; @@ -1225,6 +1444,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/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..e0ba065f445 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,6 +15,7 @@ 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.UnifiedDiffTextTest; import org.eclipse.team.tests.ui.synchronize.AllTeamSynchronizeTests; @@ -26,6 +27,7 @@ AllTeamMappingTests.class, // AllTeamSynchronizeTests.class, // SaveableCompareEditorInputTest.class, // + UnifiedDiffFoldRegionsTest.class, // UnifiedDiffManagerTest.class, // UnifiedDiffTextTest.class, // }) 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..25f4e74c373 --- /dev/null +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffFoldRegionsTest.java @@ -0,0 +1,231 @@ +/******************************************************************************* + * 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.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 + ":"); + } + } + } + + // ------------------------------------------------------------------ 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 { + List regions = UnifiedDiffManager.unchangedFoldRegions(document, Arrays.asList(diffs), + UnifiedDiffMode.OVERLAY_READ_ONLY_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 { + 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<>(), 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()); + } +}