From fee6afc769c80d71f986fe4871aec42d1406fd68 Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Thu, 30 Jul 2026 19:12:28 +0200 Subject: [PATCH] Read the compared contents once, in the background Opening a compare editor read each side three times: once for content type detection, once for the text heuristic and once for the document shown in the viewer, all three on the UI thread and each one paying whatever the provider charges for a stream. For an EGit revision that means inflating the blob and running the smudge filters three times over. The background job that prepares the input now reads the contents once and the sniffing and the merge viewer are served from that. Elements backed by a shared document are skipped, since those come from the file buffer and never read a stream anyway. An entry is dropped as soon as it became a document, so at most one input's contents are held at a time. CompareOpenEfficiencyTest's bound drops from 3 reads per side to 1. Contributes to https://github.com/eclipse-platform/eclipse.platform/issues/2795 --- .../contentmergeviewer/TextMergeViewer.java | 6 +- .../compare/internal/CompareUIPlugin.java | 77 ++++++++++++++++++- .../eclipse/compare/internal/Utilities.java | 13 ++++ .../tests/CompareOpenEfficiencyTest.java | 5 +- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/contentmergeviewer/TextMergeViewer.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/contentmergeviewer/TextMergeViewer.java index 06d16a0519a..e9ecc883494 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/contentmergeviewer/TextMergeViewer.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/contentmergeviewer/TextMergeViewer.java @@ -893,7 +893,11 @@ private IDocument createDocument() { try { String encoding = internalGetEncoding(); - s = Utilities.readString(sca, encoding); + // The background job may have read the contents already; decoding is + // all that is left then. + byte[] prefetched = CompareUIPlugin.takePrefetchedContents(fElement); + s = prefetched != null ? Utilities.readString(prefetched, encoding) + : Utilities.readString(sca, encoding); } catch (CoreException ex) { this.fViewer.setError(fLeg, ex.getMessage()); } 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..ec471fe561b 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 @@ -17,6 +17,7 @@ package org.eclipse.compare.internal; import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.ref.Reference; @@ -32,6 +33,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -77,6 +79,7 @@ import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.core.runtime.Platform; import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.SubMonitor; import org.eclipse.core.runtime.content.IContentDescription; import org.eclipse.core.runtime.content.IContentType; import org.eclipse.core.runtime.content.IContentTypeManager; @@ -285,6 +288,15 @@ private static final class SniffedElement { } } + /** + * Contents read ahead while preparing an input, keyed by element identity. They + * are written in the background job and read on the UI thread, and an entry is + * dropped as soon as it became a document, so at most one input's contents are + * held at a time. + */ + private static final Map fPrefetchedContents= Collections + .synchronizedMap(new IdentityHashMap<>()); + public static final int NO_DIFFERENCE = 10000; /** @@ -900,6 +912,9 @@ public IStatus prepareInput(CompareEditorInput input, IProgressMonitor monitor) if (input.getCompareResult() == null) { return new Status(IStatus.ERROR, CompareUIPlugin.PLUGIN_ID, NO_DIFFERENCE, Utilities.getString("CompareUIPlugin.noDifferences"), null); //$NON-NLS-1$ } + // Still off the UI thread here, so this is the place to read the contents + // that the viewer would otherwise read while the user waits. + prefetchContents(input.getCompareResult(), monitor); return Status.OK_STATUS; } catch (InterruptedException e) { throw new OperationCanceledException(); @@ -1565,6 +1580,60 @@ private static IContentType getContentType(ITypedElement element) { return sniffed.contentType; } + /** + * Reads the contents of the given input's elements once, so that the sniffing + * and the merge viewer do not have to open the streams again on the UI thread. + * Elements that come with a shared document are left alone: those are served + * from the file buffer and never read a stream. + */ + public static void prefetchContents(Object compareResult, IProgressMonitor monitor) { + if (!(compareResult instanceof ICompareInput input)) { + return; + } + fPrefetchedContents.clear(); + SubMonitor progress= SubMonitor.convert(monitor, 3); + prefetchContents(input.getAncestor(), progress.split(1)); + prefetchContents(input.getLeft(), progress.split(1)); + prefetchContents(input.getRight(), progress.split(1)); + } + + private static void prefetchContents(ITypedElement element, IProgressMonitor monitor) { + if (!(element instanceof IStreamContentAccessor accessor) || hasSharedDocument(element)) { + return; + } + try (InputStream stream= accessor.getContents()) { + if (stream != null) { + fPrefetchedContents.put(element, stream.readAllBytes()); + } + } catch (CoreException | IOException | OutOfMemoryError e) { + // not fatal: whoever needs the content reads the stream itself + } finally { + monitor.done(); + } + } + + private static boolean hasSharedDocument(ITypedElement element) { + ISharedDocumentAdapter adapter= SharedDocumentAdapterWrapper.getAdapter(element); + return adapter != null && adapter.getDocumentKey(element) != null; + } + + /** + * Returns the prefetched contents of the given element and forgets them, or + * null if nothing was read ahead for it. + */ + public static byte[] takePrefetchedContents(Object element) { + return fPrefetchedContents.remove(element); + } + + /** The contents of the given element, read ahead if that already happened. */ + private static InputStream contentsOf(ITypedElement element) throws CoreException { + byte[] prefetched= fPrefetchedContents.get(element); + if (prefetched != null) { + return new ByteArrayInputStream(prefetched); + } + return element instanceof IStreamContentAccessor accessor ? accessor.getContents() : null; + } + private static IContentType computeContentType(ITypedElement element) { String name= element.getName(); IContentType ct= null; @@ -1579,9 +1648,9 @@ private static IContentType computeContentType(ITypedElement element) { } } } - if (element instanceof IStreamContentAccessor isa) { + if (element instanceof IStreamContentAccessor) { try { - InputStream is= isa.getContents(); + InputStream is= contentsOf(element); if (is != null) { try (InputStream bis = new BufferedInputStream(is)) { ct= fgContentTypeManager.findContentTypeFor(is, name); @@ -1711,10 +1780,10 @@ private static String guessType(ITypedElement input) { } private static String computeGuessType(ITypedElement input) { - if (input instanceof IStreamContentAccessor sca) { + if (input instanceof IStreamContentAccessor) { InputStream is= null; try { - is= sca.getContents(); + is= contentsOf(input); if (is == null) { return null; } diff --git a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/Utilities.java b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/Utilities.java index ba1dc5c5897..a8c82e64411 100644 --- a/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/Utilities.java +++ b/team/bundles/org.eclipse.compare/compare/org/eclipse/compare/internal/Utilities.java @@ -19,6 +19,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URL; @@ -582,6 +583,18 @@ private static IStatus addStatus(IStatus status, IStatus entry) { // encoding + /** + * Decodes already read contents, with the same encoding fallback as + * {@link #readString(IStreamContentAccessor, String)}. + */ + public static String readString(byte[] contents, String encoding) { + try { + return new String(contents, encoding); + } catch (UnsupportedEncodingException e) { + return new String(contents, Charset.defaultCharset()); + } + } + public static String readString(IStreamContentAccessor sca, String encoding) throws CoreException { String s = null; try { diff --git a/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/CompareOpenEfficiencyTest.java b/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/CompareOpenEfficiencyTest.java index c3fa70ae74a..4c7bccf752f 100644 --- a/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/CompareOpenEfficiencyTest.java +++ b/team/tests/org.eclipse.compare.tests/src/org/eclipse/compare/tests/CompareOpenEfficiencyTest.java @@ -55,9 +55,10 @@ public class CompareOpenEfficiencyTest { /** * Upper bound for the {@code getContents()} calls per side on one compare editor - * open: content-type detection, text heuristic, and the document itself. + * open: the background job reads the contents once and everything else is served + * from it. */ - private static final int MAX_GET_CONTENTS_PER_SIDE = 3; + private static final int MAX_GET_CONTENTS_PER_SIDE = 1; private static final long TIMEOUT_MILLIS = 30_000;