From b50fcaf950c01dba92854e5ddc5818c843b3c584 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 | 6 + .../compare/unifieddiff/UnifiedDiff.java | 13 +- .../UnifiedDiffCodeMiningProvider.java | 56 +++++ .../internal/UnifiedDiffManager.java | 202 +++++++++++++++++- 6 files changed, 279 insertions(+), 2 deletions(-) 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 963842dc716..72da9cadb7d 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_cannotEditFile_title; public static String UnifiedDiff_cannotEditFile_message; + 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 6597fe147c6..fcb018bd95d 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_cannotEditFile_title=Cannot edit file UnifiedDiff_cannotEditFile_message=Cannot edit file {0}: {1} +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 f380ca3d85f..1364b4d33f4 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 @@ -263,6 +263,10 @@ Collection getAll() { 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. */ @@ -627,6 +631,7 @@ private boolean openUnifiedDiffInEditor(final CompareEditorInput input, final IW : null) .ignoreWhiteSpace(Utilities.getBoolean(input.getCompareConfiguration(), CompareConfiguration.IGNORE_WHITESPACE, false)) + .foldUnchanged(UNIFIED_DIFF_CONTEXT_LINES) .open(); return status.isOK(); } @@ -654,6 +659,7 @@ private boolean openUnifiedDiffInEditor(final CompareEditorInput input, final IW : null) .ignoreWhiteSpace(Utilities.getBoolean(input.getCompareConfiguration(), CompareConfiguration.IGNORE_WHITESPACE, false)) + .foldUnchanged(UNIFIED_DIFF_CONTEXT_LINES) .open(); return status.isOK(); } 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 7fb75205292..da4610854b8 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 @@ -25,6 +25,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; @@ -49,6 +50,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; @@ -156,6 +158,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); } } @@ -164,9 +169,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; }); } @@ -274,6 +283,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 52995417231..6f1fc15bdd1 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 @@ -67,6 +67,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.osgi.util.NLS; import org.eclipse.swt.SWT; @@ -102,6 +104,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$ @@ -120,10 +123,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) { @@ -310,6 +315,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)); @@ -319,6 +328,194 @@ 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 || unifiedDiffs.isEmpty()) { + return; + } + // 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(); + Map foldsToAdd = new HashMap<>(); + 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 <= unifiedDiffs.size(); i++) { + boolean atStart = i == 0; + boolean atEnd = i == unifiedDiffs.size(); + int gapEnd = atEnd ? lineCount : document.getLineOfOffset(unifiedDiffs.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) { + foldsToAdd.put(new UnifiedDiffFoldAnnotation(), new Position(offset, end - offset)); + } + } + if (!atEnd) { + UnifiedDiff diff = unifiedDiffs.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; + } + if (!foldsToAdd.isEmpty()) { + addFoldChangeListener(viewer); + projectionModel.replaceAnnotations(null, foldsToAdd); + } + } + + /** + * 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; @@ -1174,6 +1371,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)