From 4fdea1704891dde44eb31dfc23766b8a7943a022 Mon Sep 17 00:00:00 2001 From: Tobias Melcher Date: Mon, 9 Nov 2026 19:21:17 +0100 Subject: [PATCH] Add unified diff resolution events Expose resolution events from the unified-diff builder and report accepted, rejected, and manually deleted hunks. Keep lifecycle cleanup consistent for manual changes and suppress events for two-way compare navigation cleanup. Handle undo at ABOUT_TO_UNDO and refresh remaining code minings after delayed replacements. Add coverage for event delivery, manual hunk resolution, undo timing, listener reentrancy, and code-mining positioning. --- .../compare/internal/CompareUIPlugin.java | 2 +- .../compare/unifieddiff/UnifiedDiff.java | 15 +- .../compare/unifieddiff/UnifiedDiffEvent.java | 83 +++++ .../unifieddiff/UnifiedDiffEventListener.java | 28 ++ .../internal/AcceptAllRunnable.java | 7 + .../internal/HideAllDiffsRunnable.java | 21 ++ .../unifieddiff/internal/KeepAllRunnable.java | 8 + .../unifieddiff/internal/UndoAllRunnable.java | 7 + .../internal/UnifiedDiffManager.java | 304 ++++++++++++++-- .../team/tests/ui/UnifiedDiffManagerTest.java | 326 ++++++++++++++++++ 10 files changed, 761 insertions(+), 40 deletions(-) create mode 100644 team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEvent.java create mode 100644 team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEventListener.java 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 31d51e5cd68..17a10d9bb3f 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 @@ -844,7 +844,7 @@ private Action createOpenTwoWayCompareAction(final CompareEditorInput input, fin Action openTwoWayCompare = new Action(null, image) { @Override public void run() { - new HideAllDiffsRunnable(textEditor).run(); + new HideAllDiffsRunnable(textEditor, false).run(); if (input.canRunAsJob()) { openEditorInBackground(input, page, editor, activate); } else { 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 6349be26b01..2bc2ed2ec0a 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 @@ -107,6 +107,7 @@ public static final class Builder { private char editorContributor = MergeViewerContentProvider.LEFT_CONTRIBUTOR; private char sourceContributor = MergeViewerContentProvider.RIGHT_CONTRIBUTOR; private int foldContextLines = -1; + private UnifiedDiffEventListener eventListener; private final Map toolbarActionTexts = new EnumMap<>(ToolbarAction.class); private final Map toolbarActionImages = new EnumMap<>(ToolbarAction.class); @@ -176,6 +177,18 @@ public Builder toolbarActionImage(ToolbarAction action, ImageDescriptor image) { return this; } + /** + * Sets the listener notified when a diff hunk is resolved. The listener + * is invoked on the UI thread once for each hunk that the user resolves. + * + * @param listener the listener, or null to receive no events + * @return this builder + */ + public Builder eventListener(UnifiedDiffEventListener listener) { + this.eventListener = listener; + return this; + } + /** * Collapses unchanged regions between diffs, keeping the given number of * context lines (at least one) around each change. A negative value disables @@ -194,7 +207,7 @@ public IStatus open() { return UnifiedDiffManager.open(editor, source, mode, additionalActions, tokenComparatorFactory, ignoreWhitespaceContributorFactory, compareFilters, editorContributor, sourceContributor, ignoreWhiteSpace, foldContextLines, - new ToolbarActionPresentations(toolbarActionTexts, toolbarActionImages)); + new ToolbarActionPresentations(toolbarActionTexts, toolbarActionImages), eventListener); } } } diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEvent.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEvent.java new file mode 100644 index 00000000000..aaf81fd48e3 --- /dev/null +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEvent.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright (c) 2026 SAP + * + * 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: + * SAP - initial implementation + *******************************************************************************/ +package org.eclipse.compare.unifieddiff; + +import java.util.Objects; + +/** + * Describes a unified-diff hunk that was resolved by the user. + *

+ * The range and line identify the hunk in the editor document at the time it is + * resolved. The editor and compared-source code are the unmodified snippets + * used when the unified diff was created. + */ +public final class UnifiedDiffEvent { + + /** How the hunk was resolved. */ + public enum Resolution { + /** The compared/new hunk was selected. */ + ACCEPTED, + /** The original/current hunk was retained. */ + REJECTED, + /** The user manually removed or replaced the complete displayed hunk. */ + MANUALLY_DELETED + } + + private final Resolution resolution; + private final int line; + private final int offset; + private final int length; + private final String editorCode; + private final String comparedSourceCode; + + public UnifiedDiffEvent(Resolution resolution, int line, int offset, int length, String editorCode, + String comparedSourceCode) { + this.resolution = Objects.requireNonNull(resolution); + this.line = line; + this.offset = offset; + this.length = length; + this.editorCode = Objects.requireNonNull(editorCode); + this.comparedSourceCode = Objects.requireNonNull(comparedSourceCode); + } + + /** Returns whether the hunk was accepted, rejected, or manually deleted. */ + public Resolution getResolution() { + return resolution; + } + + /** Returns the one-based line at which the hunk starts. */ + public int getLine() { + return line; + } + + /** Returns the offset of the hunk in the editor document. */ + public int getOffset() { + return offset; + } + + /** Returns the length of the hunk in the editor document. */ + public int getLength() { + return length; + } + + /** Returns the code in the editor document when the diff was created. */ + public String getEditorCode() { + return editorCode; + } + + /** Returns the code in the source compared with the editor document. */ + public String getComparedSourceCode() { + return comparedSourceCode; + } +} diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEventListener.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEventListener.java new file mode 100644 index 00000000000..1c924b4b075 --- /dev/null +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/UnifiedDiffEventListener.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2026 SAP + * + * 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: + * SAP - initial implementation + *******************************************************************************/ +package org.eclipse.compare.unifieddiff; + +/** + * Receives a notification when a unified-diff hunk is resolved. + */ +@FunctionalInterface +public interface UnifiedDiffEventListener { + + /** + * Invoked on the UI thread when a unified-diff hunk is resolved. + * + * @param event the resolved hunk + */ + void unifiedDiffResolved(UnifiedDiffEvent event); +} diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/AcceptAllRunnable.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/AcceptAllRunnable.java index 834e5a12d2a..4a8fb62ea0a 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/AcceptAllRunnable.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/AcceptAllRunnable.java @@ -23,6 +23,7 @@ import org.eclipse.compare.internal.CompareMessages; import org.eclipse.compare.internal.CompareUIPlugin; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiffAnnotation; import org.eclipse.jface.resource.ImageDescriptor; @@ -69,6 +70,7 @@ public void run() { } List positions = new ArrayList<>(); List replaceStrings = new ArrayList<>(); + List resolvedDiffs = new ArrayList<>(); for (UnifiedDiff diff : diffs1) { List annos = getAllAnnotationsForUnifiedDiff(model, diff); int unifiedDiffAdditionCount = 0; @@ -79,6 +81,7 @@ public void run() { throw new IllegalStateException("Multiple UnifiedDiffAnnotation for one UnifiedDiff found"); //$NON-NLS-1$ } Position pos = model.getPosition(lanno); + resolvedDiffs.add(UnifiedDiffManager.prepareEvent(diff, UnifiedDiffEvent.Resolution.ACCEPTED, pos)); positions.add(pos); replaceStrings.add(diff.rightStr); } @@ -96,15 +99,19 @@ public void run() { // we have to insert with delay because otherwise the line header code minings // cannot be deleted runAfterRepaintFinished(tw, () -> { + List completed = new ArrayList<>(); for (int i = positions.size() - 1; i >= 0; i--) { Position pos = positions.get(i); String replaceStr = replaceStrings.get(i); try { tv.getDocument().replace(pos.offset, pos.length, replaceStr); + completed.add(resolvedDiffs.get(i)); } catch (BadLocationException e) { + UnifiedDiffManager.cancelEvent(resolvedDiffs.get(i)); UnifiedDiffManager.error(e); } } + UnifiedDiffManager.fireEvents(completed); }); } } diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/HideAllDiffsRunnable.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/HideAllDiffsRunnable.java index dfa6c62fc22..1146f63e2cb 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/HideAllDiffsRunnable.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/HideAllDiffsRunnable.java @@ -18,10 +18,13 @@ import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.getAllAnnotationsForUnifiedDiff; import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.removeAnnotationModelListener; +import java.util.ArrayList; import java.util.List; import org.eclipse.compare.internal.CompareMessages; import org.eclipse.compare.internal.CompareUIPlugin; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent; +import org.eclipse.compare.unifieddiff.UnifiedDiffMode; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; import org.eclipse.jface.resource.ImageDescriptor; import org.eclipse.jface.text.ITextViewer; @@ -36,15 +39,26 @@ public class HideAllDiffsRunnable implements Runnable { private IAnnotationModel model; private ITextViewer tv; + private final boolean notifyEvents; public HideAllDiffsRunnable(ITextViewer tv, IAnnotationModel model) { + this(tv, model, true); + } + + public HideAllDiffsRunnable(ITextViewer tv, IAnnotationModel model, boolean notifyEvents) { this.tv = tv; this.model = model; + this.notifyEvents = notifyEvents; } public HideAllDiffsRunnable(ITextEditor textEditor) { + this(textEditor, true); + } + + public HideAllDiffsRunnable(ITextEditor textEditor, boolean notifyEvents) { this.tv = textEditor.getAdapter(ITextViewer.class); this.model = UnifiedDiffManager.annotationModelOf(textEditor); + this.notifyEvents = notifyEvents; } public String getLabel() { @@ -69,9 +83,15 @@ public void run() { if (diffs1 == null) { return; } + List resolvedDiffs = new ArrayList<>(); for (UnifiedDiff diff : diffs1) { List annos = getAllAnnotationsForUnifiedDiff(model, diff); for (var lanno : annos) { + if (notifyEvents && lanno instanceof UnifiedDiffManager.UnifiedDiffAnnotation) { + UnifiedDiffEvent.Resolution resolution = diff.mode.equals(UnifiedDiffMode.REPLACE_MODE) + ? UnifiedDiffEvent.Resolution.ACCEPTED : UnifiedDiffEvent.Resolution.REJECTED; + resolvedDiffs.add(UnifiedDiffManager.prepareEvent(diff, resolution, model.getPosition(lanno))); + } // markDeleted must be called before removing it from the model so that the vertical indent of line header mining is properly reset lanno.markDeleted(true); model.removeAnnotation(lanno); @@ -82,5 +102,6 @@ public void run() { ext.updateCodeMinings(); } disposeUnifiedDiff(tv, model, tv.getTextWidget()); + UnifiedDiffManager.fireEvents(resolvedDiffs); } } diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/KeepAllRunnable.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/KeepAllRunnable.java index 4884f363691..a6806e1d0bb 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/KeepAllRunnable.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/KeepAllRunnable.java @@ -17,9 +17,11 @@ import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.get; import static org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.getAllAnnotationsForUnifiedDiff; +import java.util.ArrayList; import java.util.List; import org.eclipse.compare.internal.CompareMessages; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; import org.eclipse.jface.text.ITextViewer; import org.eclipse.jface.text.source.Annotation; @@ -45,9 +47,14 @@ public void run() { if (diffs1 == null) { return; } + List resolvedDiffs = new ArrayList<>(); for (UnifiedDiff diff : diffs1) { List annos = getAllAnnotationsForUnifiedDiff(model, diff); for (var lanno : annos) { + if (lanno instanceof UnifiedDiffManager.UnifiedDiffAnnotation) { + resolvedDiffs.add(UnifiedDiffManager.prepareEvent(diff, UnifiedDiffEvent.Resolution.ACCEPTED, + model.getPosition(lanno))); + } model.removeAnnotation(lanno); } } @@ -56,5 +63,6 @@ public void run() { ext.updateCodeMinings(); } disposeUnifiedDiff(tv, model, tv.getTextWidget()); + UnifiedDiffManager.fireEvents(resolvedDiffs); } } diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UndoAllRunnable.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UndoAllRunnable.java index 6330efd2c40..8f508e17d5c 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UndoAllRunnable.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/unifieddiff/internal/UndoAllRunnable.java @@ -23,6 +23,7 @@ import java.util.List; import org.eclipse.compare.internal.CompareMessages; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiff; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager.UnifiedDiffAnnotation; import org.eclipse.jface.text.BadLocationException; @@ -57,6 +58,7 @@ public void run() { } List positions = new ArrayList<>(); List replaceStrings = new ArrayList<>(); + List resolvedDiffs = new ArrayList<>(); for (UnifiedDiff diff : diffs1) { List annos = getAllAnnotationsForUnifiedDiff(model, diff); int unifiedDiffAdditionCount = 0; @@ -67,6 +69,7 @@ public void run() { throw new IllegalStateException("Multiple UnifiedDiffAnnotation for one UnifiedDiff found"); //$NON-NLS-1$ } Position pos = model.getPosition(lanno); + resolvedDiffs.add(UnifiedDiffManager.prepareEvent(diff, UnifiedDiffEvent.Resolution.REJECTED, pos)); positions.add(pos); replaceStrings.add(diff.leftStr); } @@ -84,15 +87,19 @@ public void run() { // we have to insert with delay because otherwise the line header code minings // cannot be deleted runAfterRepaintFinished(tw, () -> { + List completed = new ArrayList<>(); for (int i = positions.size() - 1; i >= 0; i--) { Position pos = positions.get(i); String replaceStr = replaceStrings.get(i); try { tv.getDocument().replace(pos.offset, pos.length, replaceStr); + completed.add(resolvedDiffs.get(i)); } catch (BadLocationException e) { + UnifiedDiffManager.cancelEvent(resolvedDiffs.get(i)); error(e); } } + UnifiedDiffManager.fireEvents(completed); }); } } 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 62aa2103b0e..45bb66d104b 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 @@ -39,6 +39,8 @@ import org.eclipse.compare.unifieddiff.UnifiedDiff.IgnoreWhitespaceContributorFactory; import org.eclipse.compare.unifieddiff.UnifiedDiff.TokenComparatorFactory; import org.eclipse.compare.unifieddiff.UnifiedDiff.ToolbarAction; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent; +import org.eclipse.compare.unifieddiff.UnifiedDiffEventListener; import org.eclipse.compare.unifieddiff.UnifiedDiffMode; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider.UnifiedDiffLineHeaderCodeMining; import org.eclipse.core.resources.IFile; @@ -66,8 +68,10 @@ import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.DocumentEvent; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentExtension4; +import org.eclipse.jface.text.IDocumentListener; import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.ITextViewer; @@ -114,6 +118,7 @@ import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.text.undo.DocumentUndoEvent; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoListener; import org.eclipse.text.undo.IDocumentUndoManager; @@ -133,6 +138,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 DOCUMENT_LISTENER_KEY = "UNIFIED_DIFF_DOCUMENT_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_CONTEXT_ID = "org.eclipse.compare.unifiedDiffScope"; //$NON-NLS-1$ private static final String UNIFIED_DIFF_COMMAND_HANDLERS_KEY = "UNIFIED_DIFF_COMMAND_HANDLERS_KEY"; //$NON-NLS-1$ @@ -236,6 +242,17 @@ public static IStatus open(ITextEditor editor, String source, UnifiedDiffMode mo IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory, ICompareFilter[] compareFilters, char leftContributor, char rightContributor, boolean ignoreWhiteSpace, int foldContextLines, ToolbarActionPresentations toolbarActionPresentations) { + return open(editor, source, mode, additionalActions, tokenComparatorFactory, ignoreWhitespaceContributorFactory, + compareFilters, leftContributor, rightContributor, ignoreWhiteSpace, foldContextLines, + toolbarActionPresentations, null); + } + + public static IStatus open(ITextEditor editor, String source, UnifiedDiffMode mode, List additionalActions, + TokenComparatorFactory tokenComparatorFactory, + IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory, ICompareFilter[] compareFilters, + char leftContributor, char rightContributor, boolean ignoreWhiteSpace, + int foldContextLines, ToolbarActionPresentations toolbarActionPresentations, + UnifiedDiffEventListener eventListener) { ITextViewer viewer = editor.getAdapter(ITextViewer.class); IDocument leftDocument = editor.getDocumentProvider().getDocument(editor.getEditorInput()); IAnnotationModel editorModel = annotationModelOf(editor); @@ -246,7 +263,7 @@ public static IStatus open(ITextEditor editor, String source, UnifiedDiffMode mo IFile file = editor.getEditorInput().getAdapter(IFile.class); IStatus status = open(viewer, leftDocument, model, file, source, mode, additionalActions, tokenComparatorFactory, ignoreWhitespaceContributorFactory, compareFilters, leftContributor, rightContributor, ignoreWhiteSpace, - foldContextLines, toolbarActionPresentations); + foldContextLines, toolbarActionPresentations, eventListener); if (status.isOK()) { registerCommandHandlers(editor, viewer, model); } @@ -380,6 +397,18 @@ public static IStatus open(ITextViewer viewer, IDocument leftDocument, IAnnotati IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory, ICompareFilter[] compareFilters, char leftContributor, char rightContributor, boolean ignoreWhiteSpace, int foldContextLines, ToolbarActionPresentations toolbarActionPresentations) { + return open(viewer, leftDocument, model, file, source, mode, additionalActions, tokenComparatorFactory, + ignoreWhitespaceContributorFactory, compareFilters, leftContributor, rightContributor, ignoreWhiteSpace, + foldContextLines, toolbarActionPresentations, null); + } + + private static IStatus open(ITextViewer viewer, IDocument leftDocument, IAnnotationModel model, IFile file, + String source, UnifiedDiffMode mode, List additionalActions, + TokenComparatorFactory tokenComparatorFactory, + IgnoreWhitespaceContributorFactory ignoreWhitespaceContributorFactory, ICompareFilter[] compareFilters, + char leftContributor, char rightContributor, boolean ignoreWhiteSpace, + int foldContextLines, ToolbarActionPresentations toolbarActionPresentations, + UnifiedDiffEventListener eventListener) { Object request = beginOpenRequest(viewer); if (viewer instanceof ProjectionViewer pv) { @@ -413,6 +442,9 @@ public static IStatus open(ITextViewer viewer, IDocument leftDocument, IAnnotati return Status.CANCEL_STATUS; } } + for (UnifiedDiff unifiedDiff : unifiedDiffs) { + unifiedDiff.eventListener = eventListener; + } Map diffByAnno = new HashMap<>(); // When the model supports batch updates, stage annotations into a map and flush // them with a single replaceAnnotations() call so listeners receive one event @@ -499,6 +531,7 @@ public static IStatus open(ITextViewer viewer, IDocument leftDocument, IAnnotati drawToolBarForAllDiffs(viewer, model, additionalActions, mode, toolbarActionPresentations); addUndoListener(viewer, leftDocument, model); + addDocumentListener(viewer, leftDocument, model); addAnnoModelChangeListener(viewer, model); if (foldContextLines >= 0 && viewer instanceof ProjectionViewer pv) { @@ -969,49 +1002,31 @@ public UnifiedDiffAnnotationmodelListener(ITextViewer tv) { @Override public void modelChanged(AnnotationModelEvent event) { - Annotation[] annos = event.getRemovedAnnotations(); - if (annos == null) { + Annotation[] annotations = event.getRemovedAnnotations(); + if (annotations == null) { return; } - List unifiedDiffsToDelete = new ArrayList<>(); - for (var anno : annos) { + List removedDiffs = new ArrayList<>(); + for (Annotation annotation : annotations) { // Line-header annotations belong to the code-mining framework and are // routinely removed and recreated when it reconciles. Only removal of - // the primary diff annotation means that the user deleted the diff's - // document range. - if (anno instanceof UnifiedDiffAnnotation unifiedDiffAnnotation) { - unifiedDiffsToDelete.add(unifiedDiffAnnotation.getUnifiedDiff()); + // the primary diff annotation means that the user removed the hunk. + if (annotation instanceof UnifiedDiffAnnotation unifiedDiffAnnotation) { + Position position = event.getPositionOfRemovedAnnotation(annotation); + removedDiffs.add(new ResolvedDiff(unifiedDiffAnnotation.getUnifiedDiff(), + UnifiedDiffEvent.Resolution.MANUALLY_DELETED, + position == null ? null : new Position(position.offset, position.length), -1)); } } - if (unifiedDiffsToDelete.size() > 0) { + if (!removedDiffs.isEmpty()) { Display.getDefault().asyncExec(() -> { - UnifiedDiff currentToolbarUnifiedDiff = null; - Composite toolbarCompositeForOneDiff = getToolbarCompositeForOneDiff(tv.getTextWidget()); - if (toolbarCompositeForOneDiff != null) { - var currentToolbarUnifiedDiffAnno = (Annotation) toolbarCompositeForOneDiff - .getData(CURRENT_SELECTED_UNIFIED_DIFF_ANNO_KEY); - currentToolbarUnifiedDiff = getUnifiedDiffForAnno(currentToolbarUnifiedDiffAnno); - } - List container = null; - IAnnotationModel model = event.getAnnotationModel(); - for (var unifiedDiff : unifiedDiffsToDelete) { - List annos1 = getAllAnnotationsForUnifiedDiff(model, unifiedDiff); - for (var lanno : annos1) { - model.removeAnnotation(lanno); - } - if (currentToolbarUnifiedDiff != null && currentToolbarUnifiedDiff.equals(unifiedDiff)) { - disposeToolbarForOneDiff(toolbarCompositeForOneDiff, - UnifiedDiffAnnotationmodelListener.this.tv); - } - unifiedDiff.container.remove(unifiedDiff); - container = unifiedDiff.container; - } - if (UnifiedDiffAnnotationmodelListener.this.tv instanceof ISourceViewerExtension5 ext) { - ext.updateCodeMinings(); - } - if (container != null && container.size() == 0) { - disposeUnifiedDiff(UnifiedDiffAnnotationmodelListener.this.tv, model, tv.getTextWidget()); + List resolvedDiffs = new ArrayList<>(); + for (ResolvedDiff removedDiff : removedDiffs) { + UnifiedDiff diff = removedDiff.diff(); + resolvedDiffs.add(diff.eventPending || diff.eventNotified ? cleanupOnly(diff) + : prepareEvent(diff, removedDiff.resolution(), removedDiff.position())); } + resolveManually(tv, event.getAnnotationModel(), resolvedDiffs); }); } } @@ -1170,6 +1185,98 @@ public static void error(Exception e) { Platform.getLog(UnifiedDiffManager.class).error(e.getMessage(), e); } + static record ResolvedDiff(UnifiedDiff diff, UnifiedDiffEvent.Resolution resolution, Position position, int line) { + } + + static ResolvedDiff prepareEvent(UnifiedDiff diff, UnifiedDiffEvent.Resolution resolution, Position position) { + diff.eventPending = true; + return new ResolvedDiff(diff, resolution, position == null ? null : new Position(position.offset, position.length), -1); + } + + private static ResolvedDiff cleanupOnly(UnifiedDiff diff) { + return new ResolvedDiff(diff, null, null, -1); + } + + static void cancelEvent(ResolvedDiff resolved) { + if (!resolved.diff().eventNotified) { + resolved.diff().eventPending = false; + } + } + + static void fireEvents(List resolvedDiffs) { + for (ResolvedDiff resolved : resolvedDiffs) { + if (resolved.resolution() != null) { + fireEvent(resolved.diff(), resolved.resolution(), resolved.position(), resolved.line()); + } + } + } + + static void fireEvent(UnifiedDiff diff, UnifiedDiffEvent.Resolution resolution, Position position) { + fireEvent(diff, resolution, position, -1); + } + + private static void fireEvent(UnifiedDiff diff, UnifiedDiffEvent.Resolution resolution, Position position, int line) { + if (diff.eventNotified) { + return; + } + diff.eventPending = false; + diff.eventNotified = true; + UnifiedDiffEventListener listener = diff.eventListener; + if (listener == null) { + return; + } + int offset = position != null ? position.offset : diff.leftStart; + int length = position != null ? position.length : diff.leftLength; + if (line < 1) { + line = lineOf(diff.left, offset); + } + try { + listener.unifiedDiffResolved(new UnifiedDiffEvent(resolution, line, offset, length, diff.leftStr, diff.rightStr)); + } catch (RuntimeException e) { + error(e); + } + } + + private static void resolveManually(ITextViewer tv, IAnnotationModel model, List resolvedDiffs) { + if (resolvedDiffs.isEmpty()) { + return; + } + StyledText textWidget = tv.getTextWidget(); + Composite toolbarCompositeForOneDiff = getToolbarCompositeForOneDiff(textWidget); + UnifiedDiff currentToolbarUnifiedDiff = null; + if (toolbarCompositeForOneDiff != null) { + Annotation annotation = (Annotation) toolbarCompositeForOneDiff + .getData(CURRENT_SELECTED_UNIFIED_DIFF_ANNO_KEY); + currentToolbarUnifiedDiff = getUnifiedDiffForAnno(annotation); + } + for (ResolvedDiff resolved : resolvedDiffs) { + UnifiedDiff diff = resolved.diff(); + if (!diff.container.remove(diff)) { + continue; + } + if (diff == currentToolbarUnifiedDiff) { + disposeToolbarForOneDiff(toolbarCompositeForOneDiff, tv); + } + removeAnnotations(model, getAllAnnotationsForUnifiedDiff(model, diff)); + } + if (tv instanceof ISourceViewerExtension5 ext) { + ext.updateCodeMinings(); + } + if (get(tv) != null && get(tv).isEmpty()) { + disposeUnifiedDiff(tv, model, textWidget); + } + fireEvents(resolvedDiffs); + } + + private static int lineOf(IDocument document, int offset) { + try { + return document.getLineOfOffset(Math.max(0, Math.min(offset, document.getLength()))) + 1; + } catch (BadLocationException e) { + error(e); + return 1; + } + } + static final class UnifiedDiffAnnotation extends Annotation { private final UnifiedDiff unifiedDiff; @@ -1524,9 +1631,11 @@ private static void applyDiffRightStr(ITextViewer tv, IAnnotationModel model, Un List annos = getAllAnnotationsForUnifiedDiff(model, diff); List positions = new ArrayList<>(); List replaceStrings = new ArrayList<>(); + List resolvedDiffs = new ArrayList<>(); for (var lanno : annos) { if (lanno instanceof UnifiedDiffAnnotation) { Position pos = model.getPosition(lanno); + resolvedDiffs.add(prepareEvent(diff, UnifiedDiffEvent.Resolution.ACCEPTED, pos)); positions.add(pos); replaceStrings.add(diff.rightStr); } @@ -1538,15 +1647,24 @@ private static void applyDiffRightStr(ITextViewer tv, IAnnotationModel model, Un // we have to insert with delay because otherwise the line header code minings // cannot be deleted runAfterRepaintFinished(tw, () -> { + List completed = new ArrayList<>(); + boolean replaced = false; for (int i = positions.size() - 1; i >= 0; i--) { Position pos = positions.get(i); String replaceStr = replaceStrings.get(i); try { tv.getDocument().replace(pos.offset, pos.length, replaceStr); + completed.add(resolvedDiffs.get(i)); + replaced = true; } catch (BadLocationException e) { + cancelEvent(resolvedDiffs.get(i)); error(e); } } + if (replaced && tv instanceof ISourceViewerExtension5 ext) { + ext.updateCodeMinings(); + } + fireEvents(completed); }); } @@ -1587,10 +1705,23 @@ private static void dismissCurrentDiff(ITextViewer tv, IAnnotationModel model) { } } diff.container.remove(diff); - removeAnnotations(model, getAllAnnotationsForUnifiedDiff(model, diff)); + List annotations = getAllAnnotationsForUnifiedDiff(model, diff); + Position position = null; + for (Annotation annotation : annotations) { + if (annotation instanceof UnifiedDiffAnnotation) { + position = model.getPosition(annotation); + break; + } + } + ResolvedDiff resolved = prepareEvent(diff, + UnifiedDiffMode.REPLACE_MODE.equals(diff.mode) ? UnifiedDiffEvent.Resolution.ACCEPTED + : UnifiedDiffEvent.Resolution.REJECTED, + position); + removeAnnotations(model, annotations); if (tv instanceof ISourceViewerExtension5 ext) { ext.updateCodeMinings(); } + fireEvents(List.of(resolved)); } /** @@ -1665,11 +1796,13 @@ private static void drawToolbarForOneDiff(ITextViewer tv, IAnnotationModel model "Multiple UnifiedDiffAdditionAnnotation for one UnifiedDiff found"); //$NON-NLS-1$ } Position pos = model.getPosition(lanno); + ResolvedDiff resolved = prepareEvent(diff, UnifiedDiffEvent.Resolution.REJECTED, pos); // we have to insert with delay because otherwise the line header code minings // cannot be deleted runAfterRepaintFinished(tw, () -> { try { tv.getDocument().replace(pos.offset, pos.length, diff.leftStr); + fireEvents(List.of(resolved)); if (nextAnno[0] != null) { disposeToolbarForOneDiff(fToolbarShellForOneDiff, tv); Position pos1 = model.getPosition(nextAnno[0]); @@ -1678,6 +1811,7 @@ private static void drawToolbarForOneDiff(ITextViewer tv, IAnnotationModel model setToolbarLocationForOneDiff(tv, model, nextAnno[0]); } } catch (BadLocationException e) { + cancelEvent(resolved); error(e); } }); @@ -2031,6 +2165,17 @@ private Iterator annotationIteratorForLine(int widgetLine) { } } + private static Iterator annotationIterator(IAnnotationModel model, int offset, int length) { + if (model instanceof IAnnotationModelExtension2 ext) { + try { + return ext.getAnnotationIterator(offset, length, true, true); + } catch (IllegalArgumentException e) { + // fall back to the full iterator + } + } + return model.getAnnotationIterator(); + } + private static Annotation getFirstAnnotationForUnifiedDiff(IAnnotationModel model, UnifiedDiff unifiedDiff) { Iterator it = model.getAnnotationIterator(); while (it.hasNext()) { @@ -2083,10 +2228,81 @@ private static Annotation getUnifiedDiffAnnotationFromIterator(Iterator resolvedDiffs = new ArrayList<>(); + + DocumentChangeListener(ITextViewer viewer, IAnnotationModel model, IDocument document) { + this.viewer = viewer; + this.model = model; + this.document = document; + } + + @Override + public void documentAboutToBeChanged(DocumentEvent event) { + if (event.getLength() == 0) { + return; + } + Iterator annotations = annotationIterator(model, event.getOffset(), event.getLength()); + int changeEnd = event.getOffset() + event.getLength(); + while (annotations.hasNext()) { + Annotation annotation = annotations.next(); + if (!(annotation instanceof UnifiedDiffAnnotation unifiedDiffAnnotation)) { + continue; + } + Position position = model.getPosition(annotation); + boolean coversPosition = position != null && (position.length == 0 + ? event.getOffset() < position.offset && changeEnd > position.offset + : event.getOffset() <= position.offset && changeEnd >= position.offset + position.length); + if (coversPosition) { + UnifiedDiff diff = unifiedDiffAnnotation.getUnifiedDiff(); + if (!diff.eventPending && !diff.eventNotified) { + ResolvedDiff resolved = prepareEvent(diff, UnifiedDiffEvent.Resolution.MANUALLY_DELETED, position); + resolvedDiffs.add(new ResolvedDiff(resolved.diff(), resolved.resolution(), resolved.position(), + lineOf(document, position.offset))); + } + } + } + } + + @Override + public void documentChanged(DocumentEvent event) { + List changes = new ArrayList<>(resolvedDiffs); + resolvedDiffs.clear(); + if (!changes.isEmpty()) { + Display.getDefault().asyncExec(() -> resolveManually(viewer, model, changes)); + } + } + + void dispose() { + document.removeDocumentListener(this); + } + } + private static void addUndoListener(ITextViewer tv, IDocument document, IAnnotationModel model) { IDocumentUndoManager documentUndoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); var undoListener = (IDocumentUndoListener) event -> { + if ((event.getEventType() & DocumentUndoEvent.ABOUT_TO_UNDO) == 0) { + return; + } UnifiedDiff toBeDeletedDiff = null; + Position toBeDeletedPosition = null; Iterator it = model.getAnnotationIterator(); while (it.hasNext()) { Annotation anno = getUnifiedDiffAnnotationFromIterator(it); @@ -2094,10 +2310,11 @@ private static void addUndoListener(ITextViewer tv, IDocument document, IAnnotat continue; } Position pos = model.getPosition(anno); - if (pos.offset == event.getOffset()) { + if (pos != null && pos.offset == event.getOffset()) { UnifiedDiff diff = getUnifiedDiffForAnno(anno); if (diff != null && diff.rightStr.equals(event.getPreservedText())) { toBeDeletedDiff = diff; + toBeDeletedPosition = new Position(pos.offset, pos.length); break; } } @@ -2107,6 +2324,8 @@ private static void addUndoListener(ITextViewer tv, IDocument document, IAnnotat } // delete the uni diff List all = getAllAnnotationsForUnifiedDiff(model, toBeDeletedDiff); + ResolvedDiff resolved = prepareEvent(toBeDeletedDiff, UnifiedDiffEvent.Resolution.REJECTED, + toBeDeletedPosition); for (var lanno : all) { model.removeAnnotation(lanno); } @@ -2117,6 +2336,7 @@ private static void addUndoListener(ITextViewer tv, IDocument document, IAnnotat if (toBeDeletedDiff.container.isEmpty()) { disposeUnifiedDiff(tv, model, tv.getTextWidget()); } + Display.getDefault().asyncExec(() -> fireEvents(List.of(resolved))); }; tv.getTextWidget().setData(UNDO_LISTENER_KEY, undoListener); documentUndoManager.addDocumentUndoListener(undoListener); @@ -2135,6 +2355,11 @@ static void disposeUnifiedDiff(ITextViewer tv, IAnnotationModel model, StyledTex documentUndoManager.removeDocumentUndoListener(undoListener); } } + var documentListener = (DocumentChangeListener) tw.getData(DOCUMENT_LISTENER_KEY); + if (documentListener != null) { + tw.setData(DOCUMENT_LISTENER_KEY, null); + documentListener.dispose(); + } removeAnnotationModelListener(model, tw); diffsByViewer.remove(tv); @@ -2508,6 +2733,9 @@ public static class UnifiedDiff { public final List detailedDiffs = new ArrayList<>(); final List container; public final UnifiedDiffMode mode; + private UnifiedDiffEventListener eventListener; + private boolean eventPending; + private boolean eventNotified; public UnifiedDiff(IDocument left, int leftStart, int leftEnd, String leftDiffSource, IDocument right, int rightStart, int rightEnd, String rightDiffSource, List container, diff --git a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java index 73af87ab418..c93c6afaaa4 100644 --- a/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java +++ b/team/tests/org.eclipse.team.tests.core/src/org/eclipse/team/tests/ui/UnifiedDiffManagerTest.java @@ -42,8 +42,14 @@ import org.eclipse.compare.rangedifferencer.IRangeComparator; import org.eclipse.compare.unifieddiff.UnifiedDiff; import org.eclipse.compare.unifieddiff.UnifiedDiff.ToolbarAction; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent; +import org.eclipse.compare.unifieddiff.UnifiedDiffEvent.Resolution; import org.eclipse.compare.unifieddiff.UnifiedDiffMode; import org.eclipse.compare.unifieddiff.internal.AcceptAllRunnable; +import org.eclipse.compare.unifieddiff.internal.HideAllDiffsRunnable; +import org.eclipse.compare.unifieddiff.internal.KeepAllRunnable; +import org.eclipse.compare.unifieddiff.internal.UndoAllRunnable; +import org.eclipse.compare.unifieddiff.internal.UnifiedDiffCodeMiningProvider.UnifiedDiffLineHeaderCodeMining; import org.eclipse.compare.unifieddiff.internal.UnifiedDiffManager; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; @@ -62,6 +68,7 @@ import org.eclipse.jface.text.ITextViewer; import org.eclipse.jface.text.Position; import org.eclipse.jface.text.Region; +import org.eclipse.jface.text.codemining.ICodeMining; import org.eclipse.jface.text.source.Annotation; import org.eclipse.jface.text.source.IAnnotationModel; import org.eclipse.jface.text.source.IAnnotationModelExtension; @@ -85,6 +92,8 @@ import org.eclipse.swt.widgets.Event; import org.eclipse.swt.widgets.ToolBar; import org.eclipse.swt.widgets.ToolItem; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.actions.ActionFactory; @@ -1089,6 +1098,46 @@ public void testAcceptAllDiffsCommandAppliesAllChanges() throws Exception { waitForDocumentContent(RIGHT); } + @Test + public void testRevertingEarlierDiffRefreshesRemainingCodeMiningPosition() throws Exception { + String current = """ + before + new early replacement with substantially more text + between + new later replacement + after + """; + String compared = """ + before + old early + between + old later + after + """; + String afterRevert = """ + before + old early + between + new later replacement + after + """; + setEditorContent(current); + assertTrue(UnifiedDiff.create(editor, compared, UnifiedDiffMode.REVERT_MODE).open().isOK()); + + List diffs = UnifiedDiffManager.get(viewer()); + assertEquals(2, diffs.size(), "the input must produce two revert diffs"); + viewer().setSelectedRange(diffs.get(0).leftStart, 0); + + executeEditorCommand("org.eclipse.compare.acceptCurrentDiff"); //$NON-NLS-1$ + waitForDocumentContent(afterRevert); + + diffs = UnifiedDiffManager.get(viewer()); + assertEquals(1, diffs.size(), "reverting the first diff must leave the later diff active"); + UnifiedDiffLineHeaderCodeMining mining = lineHeaderMiningFor(diffs.get(0)); + assertEquals(afterRevert.indexOf("new later replacement"), mining.getPosition().offset, + "the remaining code mining must be positioned after the reverted text"); + } + @Test public void testAcceptCurrentDiffCommandAppliesCurrentChange() throws Exception { setEditorContent(LEFT); @@ -1174,6 +1223,243 @@ public void testHideCurrentDiffCommandHidesDiffAtCaretWithoutToolbar() throws Ex assertNotNull(UnifiedDiffManager.get(viewer()), "hiding one of two diffs must not dispose the unified diff"); } + @Test + public void testUnifiedDiffEventListenerReportsToolbarResolutions() throws Exception { + List events = new ArrayList<>(); + + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_MODE).eventListener(events::add).open().isOK()); + executeEditorCommand("org.eclipse.compare.acceptCurrentDiff"); //$NON-NLS-1$ + waitForDocumentContent(RIGHT); + assertResolvedEvent(events, Resolution.ACCEPTED); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_MODE).eventListener(events::add).open().isOK()); + executeEditorCommand("org.eclipse.compare.acceptAllDiffs"); //$NON-NLS-1$ + waitForDocumentContent(RIGHT); + assertResolvedEvent(events, Resolution.ACCEPTED); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.REPLACE_MODE).eventListener(events::add).open().isOK()); + executeEditorCommand("org.eclipse.compare.hideCurrentDiff"); //$NON-NLS-1$ + assertEquals(1, events.size(), "keeping one diff must report one resolution event"); + assertEquals(Resolution.ACCEPTED, events.get(0).getResolution()); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE).eventListener(events::add) + .open().isOK()); + executeEditorCommand("org.eclipse.compare.hideCurrentDiff"); //$NON-NLS-1$ + assertResolvedEvent(events, Resolution.REJECTED); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE).eventListener(events::add) + .open().isOK()); + executeEditorCommand("org.eclipse.compare.hideAllDiffs"); //$NON-NLS-1$ + assertResolvedEvent(events, Resolution.REJECTED); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.REPLACE_MODE).eventListener(events::add).open().isOK()); + waitForDocumentContent(RIGHT); + new KeepAllRunnable(viewer(), annotationModel()).run(); + waitForEvents(events, 1); + assertResolution(events, Resolution.ACCEPTED); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.REPLACE_MODE).eventListener(events::add).open().isOK()); + waitForDocumentContent(RIGHT); + new UndoAllRunnable(viewer(), annotationModel()).run(); + waitForDocumentContent(LEFT); + waitForEvents(events, 1); + assertResolution(events, Resolution.REJECTED); + + events.clear(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.REPLACE_MODE).eventListener(events::add).open().isOK()); + waitForDocumentContent(RIGHT); + new HideAllDiffsRunnable(viewer(), annotationModel()).run(); + waitForEvents(events, 1); + assertResolution(events, Resolution.ACCEPTED); + assertEquals(RIGHT, document().get()); + } + + @Test + public void testUnifiedDiffEventListenerReportsManualRangeDeletion() throws Exception { + List events = new ArrayList<>(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE).eventListener(events::add) + .open().isOK()); + + Position position = annotationModel().getPosition(annotations(annotationModel(), DELETION_ANNO_TYPE).get(0)); + document().replace(position.offset, position.length, ""); //$NON-NLS-1$ + waitForEvents(events, 1); + + assertResolvedEvent(events, Resolution.MANUALLY_DELETED); + assertNull(UnifiedDiffManager.get(viewer()), "manually deleting a hunk must dispose the unified diff"); + } + + @Test + public void testAcceptingEachOfTwoHunksRemovesItFromTheUnifiedDiff() throws Exception { + String left = """ + line one + line two + line three + line four + """; + String right = """ + line ONE changed + line two + line THREE changed + line four + """; + String afterFirstAccept = """ + line ONE changed + line two + line three + line four + """; + setEditorContent(left); + assertTrue(UnifiedDiff.create(editor, right, UnifiedDiffMode.OVERLAY_MODE).open().isOK()); + + List deletions = annotations(annotationModel(), DELETION_ANNO_TYPE); + assertEquals(2, deletions.size(), "the sample must produce two unified diffs"); + deletions.sort((first, second) -> Integer.compare(annotationModel().getPosition(first).offset, + annotationModel().getPosition(second).offset)); + viewer().setSelectedRange(annotationModel().getPosition(deletions.get(0)).offset, 0); + executeEditorCommand("org.eclipse.compare.acceptCurrentDiff"); //$NON-NLS-1$ + waitForDocumentContent(afterFirstAccept); + waitForUnifiedDiffCount(1); + + Annotation remaining = annotations(annotationModel(), DELETION_ANNO_TYPE).get(0); + viewer().setSelectedRange(annotationModel().getPosition(remaining).offset, 0); + executeEditorCommand("org.eclipse.compare.acceptCurrentDiff"); //$NON-NLS-1$ + waitForDocumentContent(right); + waitForUnifiedDiffCount(0); + assertNull(UnifiedDiffManager.get(viewer()), "accepting the final hunk must dispose the unified diff"); + } + + @Test + public void testDeletingCharacterAtPureInsertionHunkDoesNotDismissIt() throws Exception { + String left = """ + a + b + c + """; + String right = """ + a + b + NEW + c + """; + setEditorContent(left); + assertTrue(UnifiedDiff.create(editor, right, UnifiedDiffMode.OVERLAY_MODE).open().isOK()); + + List deletions = annotations(annotationModel(), DELETION_ANNO_TYPE); + assertEquals(1, deletions.size(), "the insertion must produce one unified diff"); + Position position = annotationModel().getPosition(deletions.get(0)); + assertEquals(0, position.length, "an insertion hunk has a zero-length annotation"); + document().replace(position.offset, 1, ""); //$NON-NLS-1$ + processEvents(); + + assertNotNull(UnifiedDiffManager.get(viewer()), "deleting the following character must not dismiss an insertion hunk"); + assertEquals(1, UnifiedDiffManager.get(viewer()).size(), "the insertion hunk must remain active"); + } + + @Test + public void testUnifiedDiffEventListenerReportsManualRangeReplacement() throws Exception { + List events = new ArrayList<>(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE).eventListener(events::add) + .open().isOK()); + + Position position = annotationModel().getPosition(annotations(annotationModel(), DELETION_ANNO_TYPE).get(0)); + document().replace(position.offset, position.length, "manually changed\n"); //$NON-NLS-1$ + waitForEvents(events, 1); + + assertResolvedEvent(events, Resolution.MANUALLY_DELETED); + assertNull(UnifiedDiffManager.get(viewer()), "manually replacing a hunk must dispose the unified diff"); + } + + @Test + public void testUnifiedDiffEventListenerReportsDeletionCoveringAHunk() throws Exception { + List events = new ArrayList<>(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE).eventListener(events::add) + .open().isOK()); + + Position position = annotationModel().getPosition(annotations(annotationModel(), DELETION_ANNO_TYPE).get(0)); + int prefixLength = 2; + document().replace(position.offset - prefixLength, position.length + prefixLength, ""); //$NON-NLS-1$ + waitForEvents(events, 1); + + assertResolvedEvent(events, Resolution.MANUALLY_DELETED); + assertNull(UnifiedDiffManager.get(viewer()), "deleting a range that covers a hunk must dispose it"); + } + + @Test + public void testUnifiedDiffEventListenerReportsDocumentUndoAfterTheDocumentChanges() throws Exception { + List events = new ArrayList<>(); + List documentContentsAtNotification = new ArrayList<>(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.REPLACE_MODE).eventListener(event -> { + events.add(event); + documentContentsAtNotification.add(document().get()); + }).open().isOK()); + waitForDocumentContent(RIGHT); + + IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document()); + assertTrue(undoManager.undoable(), "opening a replace-mode diff must create an undoable document change"); + undoManager.undo(); + waitForDocumentContent(LEFT); + waitForEvents(events, 1); + + assertResolution(events, Resolution.REJECTED); + assertEquals(List.of(LEFT), documentContentsAtNotification, + "the undo event must be sent after the document has been restored"); + assertNull(UnifiedDiffManager.get(viewer()), "undoing the only replace-mode hunk must dispose the unified diff"); + } + + @Test + public void testUnifiedDiffEventListenerCanOpenAnotherUnifiedDiff() throws Exception { + List events = new ArrayList<>(); + AtomicReference reentrantOpenStatus = new AtomicReference<>(); + setEditorContent(LEFT); + assertTrue(UnifiedDiff.create(editor, RIGHT, UnifiedDiffMode.OVERLAY_MODE).eventListener(event -> { + events.add(event); + reentrantOpenStatus.set(UnifiedDiff.create(editor, LEFT, UnifiedDiffMode.OVERLAY_READ_ONLY_MODE).open()); + }).open().isOK()); + + executeEditorCommand("org.eclipse.compare.acceptCurrentDiff"); //$NON-NLS-1$ + waitForDocumentContent(RIGHT); + waitForEvents(events, 1); + + assertResolvedEvent(events, Resolution.ACCEPTED); + assertNotNull(reentrantOpenStatus.get(), "the listener must have opened a replacement unified diff"); + assertTrue(reentrantOpenStatus.get().isOK(), "opening a unified diff from the listener must succeed"); + assertNotNull(UnifiedDiffManager.get(viewer()), "the listener's unified diff must remain active"); + } + + private void assertResolution(List events, Resolution resolution) { + assertEquals(1, events.size(), "exactly one resolution event must be sent for the hunk"); + assertEquals(resolution, events.get(0).getResolution()); + } + + private void assertResolvedEvent(List events, Resolution resolution) { + assertEquals(1, events.size(), "exactly one resolution event must be sent for the hunk"); + UnifiedDiffEvent event = events.get(0); + assertEquals(resolution, event.getResolution()); + assertEquals(2, event.getLine()); + assertEquals(LEFT.indexOf("line two"), event.getOffset()); //$NON-NLS-1$ + assertEquals("line two\n".length(), event.getLength()); //$NON-NLS-1$ + assertEquals("line two\n", event.getEditorCode()); //$NON-NLS-1$ + assertEquals("line TWO modified\n", event.getComparedSourceCode()); //$NON-NLS-1$ + } + @Test public void testToolbarActionPresentationCanOverrideTextAndRemoveImage() throws BadLocationException { setEditorContent(LEFT); @@ -1407,6 +1693,22 @@ public boolean canCacheFilteredRegions() { } } + private UnifiedDiffLineHeaderCodeMining lineHeaderMiningFor(UnifiedDiffManager.UnifiedDiff diff) { + Iterator it = annotationModel().getAnnotationIterator(); + while (it.hasNext()) { + Annotation annotation = it.next(); + if (annotation instanceof LineHeaderAnnotation header) { + for (ICodeMining mining : header.getMinings()) { + if (mining instanceof UnifiedDiffLineHeaderCodeMining lineHeader + && lineHeader.getUnifiedDiff() == diff) { + return lineHeader; + } + } + } + } + throw new AssertionError("line-header mining not found for remaining diff"); + } + private ITextViewer viewer() { ITextViewer viewer = editor.getAdapter(ITextViewer.class); assertNotNull(viewer, "editor must adapt to ITextViewer"); @@ -1518,6 +1820,15 @@ private boolean waitForDocument(String expected, long timeoutMillis) { return expected.equals(document().get()); } + private void waitForEvents(List events, int expectedCount) throws InterruptedException { + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline && events.size() < expectedCount) { + processEvents(); + Thread.sleep(20); + } + assertEquals(expectedCount, events.size(), "the expected unified-diff events must be sent"); + } + private static List annotations(IAnnotationModel model, String type) { List result = new ArrayList<>(); Iterator it = model.getAnnotationIterator(); @@ -1633,6 +1944,21 @@ private void waitForSelectedOffset(int expectedOffset) throws InterruptedExcepti * can be removed first). A build machine gives no guarantee the editor is ever * painted on its own, so a synthetic paint is dispatched and the document polled. */ + private void waitForUnifiedDiffCount(int expectedCount) throws InterruptedException { + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + processEvents(); + List diffs = UnifiedDiffManager.get(viewer()); + int count = diffs == null ? 0 : diffs.size(); + if (count == expectedCount) { + return; + } + Thread.sleep(20); + } + List diffs = UnifiedDiffManager.get(viewer()); + assertEquals(expectedCount, diffs == null ? 0 : diffs.size(), "unexpected number of unified diffs"); + } + private void waitForDocumentContent(String expected) { assertTrue(waitForDocument(expected, 10_000), "the accepted change must eventually reach the document");