diff --git a/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF b/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF index bcfaaeb9ae8..5402c07519f 100644 --- a/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF +++ b/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: %pluginName Bundle-SymbolicName: org.eclipse.core.resources; singleton:=true -Bundle-Version: 3.24.0.qualifier +Bundle-Version: 3.25.0.qualifier Bundle-Activator: org.eclipse.core.resources.ResourcesPlugin Bundle-Vendor: %providerName Bundle-Localization: plugin diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java index 170f4d33de5..30976341ead 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java @@ -49,6 +49,7 @@ import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IProjectDescription; import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IResourceChangeEvent; import org.eclipse.core.resources.IResourceDelta; import org.eclipse.core.resources.IResourceStatus; import org.eclipse.core.resources.IncrementalProjectBuilder; @@ -262,6 +263,11 @@ private void basicBuild(int trigger, IncrementalProjectBuilder builder, Map= 0) { getWorkManager().endUnprotected(depth); } @@ -313,23 +321,28 @@ private void basicBuild(int trigger, IncrementalProjectBuilder builder, Map null for next build - if (trigger == IncrementalProjectBuilder.FULL_BUILD) { + try { + workspace.broadcastProjectBuildEvent(builderProject, builderName, + IResourceChangeEvent.POST_PROJECT_BUILD, trigger); + } finally { + // Be sure to clean up after ourselves. + if (clean || currentBuilder.wasForgetStateRequested()) { currentBuilder.setLastBuiltTree(null); + } else if (currentBuilder.wasRememberStateRequested()) { + // If remember last build state, and FULL_BUILD + // last tree must be set to => null for next build + if (trigger == IncrementalProjectBuilder.FULL_BUILD) { + currentBuilder.setLastBuiltTree(null); + } + // else don't modify the last built tree + } else { + // remember the current state as the last built state. + ElementTree lastTree = workspace.getElementTree(); + lastTree.immutable(); + currentBuilder.setLastBuiltTree(lastTree); } - // else don't modify the last built tree - } else { - // remember the current state as the last built state. - ElementTree lastTree = workspace.getElementTree(); - lastTree.immutable(); - currentBuilder.setLastBuiltTree(lastTree); + hookEndBuild(builder); } - hookEndBuild(builder); } } finally { currentBuilders.remove(currentBuilder); diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java index 017afaec0c3..d1400ba774f 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java @@ -214,6 +214,24 @@ private void cleanUp(ElementTree lastState, int type) { } } + /** + * Dispatches a per-builder build event (PRE_PROJECT_BUILD / POST_PROJECT_BUILD). + *

+ * Unlike {@link #broadcastChanges}, this path does not compute a resource + * delta, does not coalesce against the last build tree, and does not update + * {@code lastPostBuildTree}. Per-builder events are pure timing/metadata + * signals nested inside the surrounding workspace-level PRE_BUILD/POST_BUILD + * pair, which continues to carry the aggregated delta. + *

+ */ + public void broadcastProjectBuildEvent(IProject project, String builderName, int type, int buildKind) { + if (!listeners.hasListenerFor(type)) { + return; + } + ResourceChangeEvent event = new ResourceChangeEvent(project, type, buildKind, project, builderName); + notify(getListeners(), event, false); + } + /** * Helper method for the save participant lifecycle computation. */ public void broadcastChanges(IResourceChangeListener listener, int type, IResourceDelta delta) { diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java index 43daf101a56..bbf0f8073b9 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2015 IBM Corporation and others. + * Copyright (c) 2000, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -12,6 +12,7 @@ * IBM Corporation - initial API and implementation * James Blackburn (Broadcom Corp.) - ongoing development * Lars Vogel - Bug 473427 + * Vogella GmbH - per-builder build events *******************************************************************************/ package org.eclipse.core.internal.events; @@ -32,6 +33,11 @@ public class ResourceChangeEvent extends EventObject implements IResourceChangeE private int trigger = 0; int type; + /** + * The builder id for PRE_PROJECT_BUILD / POST_PROJECT_BUILD events, or null. + */ + private String builderName; + protected ResourceChangeEvent(Object source, int type, IResource resource) { super(source); this.resource = resource; @@ -45,6 +51,12 @@ public ResourceChangeEvent(Object source, int type, int buildKind, IResourceDelt this.type = type; } + public ResourceChangeEvent(Object source, int type, int buildKind, IResource resource, String builderName) { + this(source, type, resource); + this.trigger = buildKind; + this.builderName = builderName; + } + /** * @see IResourceChangeEvent#findMarkerDeltas(String, boolean) */ @@ -110,6 +122,14 @@ public int getType() { return type; } + /** + * @see IResourceChangeEvent#getBuilderName() + */ + @Override + public String getBuilderName() { + return builderName; + } + public void setDelta(IResourceDelta value) { delta = value; } @@ -136,6 +156,12 @@ public String toDebugString() { case PRE_REFRESH : output.append("PRE_REFRESH"); //$NON-NLS-1$ break; + case PRE_PROJECT_BUILD : + output.append("PRE_PROJECT_BUILD"); //$NON-NLS-1$ + break; + case POST_PROJECT_BUILD : + output.append("POST_PROJECT_BUILD"); //$NON-NLS-1$ + break; default : output.append("?"); //$NON-NLS-1$ break; @@ -157,6 +183,9 @@ public String toDebugString() { break; } output.append("\nResource: " + (resource == null ? "null" : resource)); //$NON-NLS-1$ //$NON-NLS-2$ + if (builderName != null) { + output.append("\nBuilder: " + builderName); //$NON-NLS-1$ + } output.append("\nDelta:" + (delta == null ? " null" : ((ResourceDelta) delta).toDeepDebugString())); //$NON-NLS-1$ //$NON-NLS-2$ return output.toString(); } diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java index 3fc2de8496e..48ddd480dcd 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2008 IBM Corporation and others. + * Copyright (c) 2000, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -15,6 +15,7 @@ import java.util.Objects; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicIntegerArray; import org.eclipse.core.resources.IResourceChangeListener; /** @@ -44,22 +45,18 @@ static final class ListenerEntry { @Override public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("Listener [eventMask="); //$NON-NLS-1$ - sb.append(eventMask); - sb.append(", "); //$NON-NLS-1$ - sb.append(listener); - sb.append("]"); //$NON-NLS-1$ - return sb.toString(); + return "Listener [eventMask=" + eventMask + ", " + listener + "]"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ } } - private volatile int count1 = 0; - private volatile int count2 = 0; - private volatile int count4 = 0; - private volatile int count8 = 0; - private volatile int count16 = 0; - private volatile int count32 = 0; + /** + * Per-event-bit listener counts, indexed by + * {@code Integer.numberOfTrailingZeros(eventMask)}. An + * {@link AtomicIntegerArray} preserves the per-element volatile read + * semantics that {@link #hasListenerFor(int)} relies on outside the + * synchronized {@link #add}/{@link #remove}/{@link #clear} paths. + */ + private final AtomicIntegerArray bitCounts = new AtomicIntegerArray(Integer.SIZE); /** * The list of listeners. @@ -79,43 +76,22 @@ public synchronized void add(IResourceChangeListener listener, int mask) { remove(listener); return; } - ResourceChangeListenerList.ListenerEntry entry = new ResourceChangeListenerList.ListenerEntry(listener, mask); + ListenerEntry entry = new ListenerEntry(listener, mask); final int oldSize = listeners.size(); // check for duplicates using identity for (int i = 0; i < oldSize; ++i) { ListenerEntry oldEntry = listeners.get(i); if (oldEntry.listener == listener) { - removing(oldEntry.eventMask); - adding(mask); + adjust(oldEntry.eventMask, -1); + adjust(mask, +1); listeners.set(i, entry); return; } } - adding(mask); + adjust(mask, +1); listeners.add(entry); } - private void adding(int mask) { - if ((mask & 1) != 0) { - count1++; - } - if ((mask & 2) != 0) { - count2++; - } - if ((mask & 4) != 0) { - count4++; - } - if ((mask & 8) != 0) { - count8++; - } - if ((mask & 16) != 0) { - count16++; - } - if ((mask & 32) != 0) { - count32++; - } - } - /** * Returns a copy of the registered listeners. * @return the list of registered listeners that must not be modified @@ -125,22 +101,11 @@ public ListenerEntry[] getListeners() { } public boolean hasListenerFor(int event) { - switch (event) { - case 1: - return count1 > 0; - case 2: - return count2 > 0; - case 4: - return count4 > 0; - case 8: - return count8 > 0; - case 16: - return count16 > 0; - case 32: - return count32 > 0; - default: + // event is expected to be a single bit (a power of two) + if (event <= 0 || Integer.bitCount(event) != 1) { return false; } + return bitCounts.get(Integer.numberOfTrailingZeros(event)) > 0; } /** @@ -155,7 +120,7 @@ public synchronized void remove(IResourceChangeListener listener) { for (int i = 0; i < oldSize; ++i) { ListenerEntry oldEntry = listeners.get(i); if (oldEntry.listener == listener) { - removing(oldEntry.eventMask); + adjust(oldEntry.eventMask, -1); listeners.remove(i); return; } @@ -164,44 +129,22 @@ public synchronized void remove(IResourceChangeListener listener) { public synchronized void clear() { listeners.clear(); - count1 = 0; - count2 = 0; - count4 = 0; - count8 = 0; - count16 = 0; - count32 = 0; + for (int i = 0; i < bitCounts.length(); i++) { + bitCounts.set(i, 0); + } } - private void removing(int mask) { - if ((mask & 1) != 0) { - count1--; - } - if ((mask & 2) != 0) { - count2--; - } - if ((mask & 4) != 0) { - count4--; - } - if ((mask & 8) != 0) { - count8--; - } - if ((mask & 16) != 0) { - count16--; - } - if ((mask & 32) != 0) { - count32--; + private void adjust(int mask, int delta) { + int remaining = mask; + while (remaining != 0) { + int bit = Integer.numberOfTrailingZeros(remaining); + bitCounts.addAndGet(bit, delta); + remaining &= remaining - 1; } } @Override public String toString() { - StringBuilder builder = new StringBuilder(); - builder.append("ResourceChangeListenerList ["); //$NON-NLS-1$ - if (listeners != null) { - builder.append("listeners="); //$NON-NLS-1$ - builder.append(listeners.toString()); - } - builder.append("]"); //$NON-NLS-1$ - return builder.toString(); + return "ResourceChangeListenerList [listeners=" + listeners + "]"; //$NON-NLS-1$ //$NON-NLS-2$ } } diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java index 690cb8fe72e..c6cbc2e0443 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java @@ -458,6 +458,15 @@ public void broadcastBuildEvent(Object source, int type, int buildTrigger) { notificationManager.broadcastChanges(tree, event, false); } + /** + * Broadcasts a per-builder build event. The {@code type} must be either + * {@link IResourceChangeEvent#PRE_PROJECT_BUILD} or + * {@link IResourceChangeEvent#POST_PROJECT_BUILD}. + */ + public void broadcastProjectBuildEvent(IProject project, String builderName, int type, int buildTrigger) { + notificationManager.broadcastProjectBuildEvent(project, builderName, type, buildTrigger); + } + /** * Broadcasts an internal workspace lifecycle event to interested * internal listeners. diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java index b4d0d30fb93..3e54bb344c7 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2014 IBM Corporation and others. + * Copyright (c) 2000, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -10,6 +10,7 @@ * * Contributors: * IBM Corporation - initial API and implementation + * Vogella GmbH - per-builder build events *******************************************************************************/ package org.eclipse.core.resources; @@ -86,6 +87,26 @@ * method returns the project being refreshed. * The workspace is closed for changes during notification of these events. * + *
  • + * Before-the-fact reports of a single builder about to run on a single + * project. Event type is PRE_PROJECT_BUILD, getSource + * returns the {@link IProject} being built, getBuilderName + * returns the id of the builder that is about to run, and getBuildKind + * returns the kind of build. Fired once per builder per project, nested + * inside a surrounding PRE_BUILD/POST_BUILD pair. + * No resource delta is provided. + *
  • + *
  • + * After-the-fact reports of a single builder having run on a single project. + * Event type is POST_PROJECT_BUILD, getSource + * returns the {@link IProject} that was built, getBuilderName + * returns the id of the builder that ran, and getBuildKind + * returns the kind of build. Fired once per builder per project, nested + * inside a surrounding PRE_BUILD/POST_BUILD pair. + * No resource delta is provided. + * When the workspace is configured for parallel builds, events for + * different projects may be delivered concurrently on different threads. + *
  • * *

    * In order to handle additional event types that may be introduced @@ -179,6 +200,42 @@ public interface IResourceChangeEvent { */ int PRE_REFRESH = 32; + /** + * Event type constant (bit mask) indicating a before-the-fact report + * that a single builder is about to run on a single project. + *

    + * getSource returns the {@link IProject} being built, + * getBuilderName returns the id of the builder, and + * getBuildKind returns the kind of build. No resource + * delta is provided. See class comments for further details. + *

    + * + * @see #getType() + * @see #getSource() + * @see #getBuilderName() + * @see #getBuildKind() + * @since 3.25 + */ + int PRE_PROJECT_BUILD = 64; + + /** + * Event type constant (bit mask) indicating an after-the-fact report + * that a single builder has run on a single project. + *

    + * getSource returns the {@link IProject} that was built, + * getBuilderName returns the id of the builder, and + * getBuildKind returns the kind of build. No resource + * delta is provided. See class comments for further details. + *

    + * + * @see #getType() + * @see #getSource() + * @see #getBuilderName() + * @see #getBuildKind() + * @since 3.25 + */ + int POST_PROJECT_BUILD = 128; + /** * Returns all marker deltas of the specified type that are associated * with resource deltas for this event. If includeSubtypes @@ -262,6 +319,25 @@ public interface IResourceChangeEvent { * @see #PRE_CLOSE * @see #PRE_DELETE * @see #PRE_REFRESH + * @see #PRE_PROJECT_BUILD + * @see #POST_PROJECT_BUILD */ int getType(); + + /** + * Returns the id of the builder this event relates to, or null + * if not applicable to this type of event. + *

    + * For {@link #PRE_PROJECT_BUILD} and {@link #POST_PROJECT_BUILD} events + * this returns the builder id as declared in the project's build spec + * (the ICommand's builder name). For all other event types + * this returns null. + *

    + * + * @return the builder id, or null if not applicable + * @since 3.25 + */ + default String getBuilderName() { + return null; + } } diff --git a/resources/examples/org.eclipse.ui.examples.buildmonitor/META-INF/MANIFEST.MF b/resources/examples/org.eclipse.ui.examples.buildmonitor/META-INF/MANIFEST.MF new file mode 100644 index 00000000000..37d7b91ccfc --- /dev/null +++ b/resources/examples/org.eclipse.ui.examples.buildmonitor/META-INF/MANIFEST.MF @@ -0,0 +1,14 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Automatic-Module-Name: org.eclipse.ui.examples.buildmonitor +Bundle-Name: Build Monitor Example +Bundle-SymbolicName: org.eclipse.ui.examples.buildmonitor;singleton:=true +Bundle-Version: 1.0.0.qualifier +Bundle-Vendor: Eclipse.org +Require-Bundle: org.eclipse.core.resources;bundle-version="[3.25.0,4.0.0)", + org.eclipse.core.runtime;bundle-version="[3.34.0,4.0.0)", + org.eclipse.ui;bundle-version="3.208.0", + org.eclipse.ui.ide, + org.eclipse.jface +Bundle-ActivationPolicy: lazy +Bundle-RequiredExecutionEnvironment: JavaSE-21 diff --git a/resources/examples/org.eclipse.ui.examples.buildmonitor/about.html b/resources/examples/org.eclipse.ui.examples.buildmonitor/about.html new file mode 100644 index 00000000000..c747b51f170 --- /dev/null +++ b/resources/examples/org.eclipse.ui.examples.buildmonitor/about.html @@ -0,0 +1,36 @@ + + + + +About + + +

    About This Content

    + +

    April 20, 2026

    +

    License

    + +

    + The Eclipse Foundation makes available all content in this plug-in + ("Content"). Unless otherwise indicated below, the Content + is provided to you under the terms and conditions of the Eclipse + Public License Version 2.0 ("EPL"). A copy of the EPL is + available at http://www.eclipse.org/legal/epl-2.0. + For purposes of the EPL, "Program" will mean the Content. +

    + +

    + If you did not receive this Content directly from the Eclipse + Foundation, the Content is being redistributed by another party + ("Redistributor") and different terms and conditions may + apply to your use of any object code in the Content. Check the + Redistributor's license that was provided with the Content. If no such + license exists, contact the Redistributor. Unless otherwise indicated + below, the terms and conditions of the EPL still apply to any source + code in the Content and such source code may be obtained at http://www.eclipse.org. +

    + + + diff --git a/resources/examples/org.eclipse.ui.examples.buildmonitor/build.properties b/resources/examples/org.eclipse.ui.examples.buildmonitor/build.properties new file mode 100644 index 00000000000..e5a949cc43c --- /dev/null +++ b/resources/examples/org.eclipse.ui.examples.buildmonitor/build.properties @@ -0,0 +1,7 @@ +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + .,\ + plugin.xml,\ + about.html +src.includes = about.html diff --git a/resources/examples/org.eclipse.ui.examples.buildmonitor/plugin.xml b/resources/examples/org.eclipse.ui.examples.buildmonitor/plugin.xml new file mode 100644 index 00000000000..ade5733ec2b --- /dev/null +++ b/resources/examples/org.eclipse.ui.examples.buildmonitor/plugin.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/resources/examples/org.eclipse.ui.examples.buildmonitor/src/org/eclipse/ui/examples/buildmonitor/BuildMonitorView.java b/resources/examples/org.eclipse.ui.examples.buildmonitor/src/org/eclipse/ui/examples/buildmonitor/BuildMonitorView.java new file mode 100644 index 00000000000..261e65b0c33 --- /dev/null +++ b/resources/examples/org.eclipse.ui.examples.buildmonitor/src/org/eclipse/ui/examples/buildmonitor/BuildMonitorView.java @@ -0,0 +1,238 @@ +/******************************************************************************* + * Copyright (c) 2026 Vogella GmbH 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 + *******************************************************************************/ +package org.eclipse.ui.examples.buildmonitor; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.eclipse.core.resources.IncrementalProjectBuilder; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IToolBarManager; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.TreeViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.ui.examples.buildmonitor.BuildRecorder.BuilderEntry; +import org.eclipse.ui.examples.buildmonitor.BuildRecorder.ProjectEntry; +import org.eclipse.ui.examples.buildmonitor.BuildRecorder.Session; +import org.eclipse.ui.part.ViewPart; + +/** + * View that shows recent build sessions with per-project and per-builder timing. + * Sessions are top-level rows; expand to see per-project aggregates, expand again + * to see individual builder runs. + */ +public class BuildMonitorView extends ViewPart { + + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()); + + private BuildRecorder recorder; + private BuildRecorder.Listener recorderListener; + private TreeViewer viewer; + + @Override + public void createPartControl(Composite parent) { + recorder = new BuildRecorder(); + recorder.start(); + + viewer = new TreeViewer(parent, SWT.FULL_SELECTION | SWT.H_SCROLL | SWT.V_SCROLL); + Tree tree = viewer.getTree(); + tree.setHeaderVisible(true); + tree.setLinesVisible(true); + + addColumn("Build / Project / Builder", 320, new ColumnLabelProvider() { + @Override + public String getText(Object element) { + if (element instanceof Session s) { + return "Build " + TIME.format(s.getStartedAt()) + " — " + kindLabel(s.getBuildKind()); + } + if (element instanceof ProjectEntry p) { + return p.getProject().getName(); + } + if (element instanceof BuilderEntry b) { + return b.getBuilderName() == null ? "" : b.getBuilderName(); + } + return String.valueOf(element); + } + }); + addColumn("Pure build time", 120, new ColumnLabelProvider() { + @Override + public String getText(Object element) { + if (element instanceof Session s) { + long sum = 0; + for (ProjectEntry p : s.getProjects()) { + sum += p.getPureBuildTimeNanos(); + } + return formatMillis(sum); + } + if (element instanceof ProjectEntry p) { + return formatMillis(p.getPureBuildTimeNanos()); + } + if (element instanceof BuilderEntry b) { + return formatMillis(b.getDurationNanos()); + } + return ""; + } + }); + addColumn("Time until ready", 140, new ColumnLabelProvider() { + @Override + public String getText(Object element) { + if (element instanceof Session s) { + return formatMillis(s.getDurationNanos()); + } + if (element instanceof ProjectEntry p) { + return formatMillis(p.getTimeUntilReadyNanos()); + } + return ""; + } + }); + + viewer.setContentProvider(new BuildMonitorContentProvider()); + viewer.setInput(recorder); + + recorderListener = this::refreshAsync; + recorder.addListener(recorderListener); + + IToolBarManager toolBar = getViewSite().getActionBars().getToolBarManager(); + toolBar.add(new Action("Clear") { + @Override + public void run() { + recorder.clear(); + } + }); + } + + @Override + public void setFocus() { + viewer.getControl().setFocus(); + } + + @Override + public void dispose() { + if (recorder != null) { + if (recorderListener != null) { + recorder.removeListener(recorderListener); + } + recorder.stop(); + } + super.dispose(); + } + + private void addColumn(String title, int width, ColumnLabelProvider provider) { + TreeViewerColumn col = new TreeViewerColumn(viewer, SWT.NONE); + col.getColumn().setText(title); + col.getColumn().setWidth(width); + col.setLabelProvider(provider); + } + + private void refreshAsync() { + if (viewer == null || viewer.getControl().isDisposed()) { + return; + } + Display display = viewer.getControl().getDisplay(); + if (display.isDisposed()) { + return; + } + display.asyncExec(() -> { + if (viewer != null && !viewer.getControl().isDisposed()) { + viewer.refresh(); + } + }); + } + + private static String formatMillis(long nanos) { + if (nanos <= 0) { + return "—"; + } + double ms = nanos / 1_000_000.0; + if (ms < 1) { + return String.format(Locale.ROOT, "%.2f ms", ms); + } + if (ms < 1000) { + return String.format(Locale.ROOT, "%.0f ms", ms); + } + return String.format(Locale.ROOT, "%.2f s", ms / 1000.0); + } + + private static boolean builderDidWork(BuilderEntry b) { + return b.getDurationNanos() >= SKIPPED_BUILDER_THRESHOLD_NANOS; + } + + private static boolean projectDidWork(ProjectEntry p) { + return p.getBuilders().stream().anyMatch(BuildMonitorView::builderDidWork); + } + + private static String kindLabel(int kind) { + return switch (kind) { + case IncrementalProjectBuilder.FULL_BUILD -> "FULL"; + case IncrementalProjectBuilder.INCREMENTAL_BUILD -> "INCREMENTAL"; + case IncrementalProjectBuilder.AUTO_BUILD -> "AUTO"; + case IncrementalProjectBuilder.CLEAN_BUILD -> "CLEAN"; + default -> "KIND=" + kind; + }; + } + + /** Hide builders that short-circuited via needsBuild (< 100 µs overhead). */ + private static final long SKIPPED_BUILDER_THRESHOLD_NANOS = 100_000L; + + private static final class BuildMonitorContentProvider implements ITreeContentProvider { + @Override + public Object[] getElements(Object input) { + if (input instanceof BuildRecorder r) { + List sessions = r.getSessions(); + // newest first + Object[] arr = sessions.toArray(); + Collections.reverse(Arrays.asList(arr)); + return arr; + } + return new Object[0]; + } + + @Override + public Object[] getChildren(Object parent) { + if (parent instanceof Session s) { + return s.getProjects().stream() + .filter(BuildMonitorView::projectDidWork) + .toArray(); + } + if (parent instanceof ProjectEntry p) { + return p.getBuilders().stream() + .filter(BuildMonitorView::builderDidWork) + .toArray(); + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof Session s) { + return s.getProjects().stream().anyMatch(BuildMonitorView::projectDidWork); + } + if (element instanceof ProjectEntry p) { + return p.getBuilders().stream().anyMatch(BuildMonitorView::builderDidWork); + } + return false; + } + } +} diff --git a/resources/examples/org.eclipse.ui.examples.buildmonitor/src/org/eclipse/ui/examples/buildmonitor/BuildRecorder.java b/resources/examples/org.eclipse.ui.examples.buildmonitor/src/org/eclipse/ui/examples/buildmonitor/BuildRecorder.java new file mode 100644 index 00000000000..479488b6648 --- /dev/null +++ b/resources/examples/org.eclipse.ui.examples.buildmonitor/src/org/eclipse/ui/examples/buildmonitor/BuildRecorder.java @@ -0,0 +1,299 @@ +/******************************************************************************* + * Copyright (c) 2026 Vogella GmbH 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 + *******************************************************************************/ +package org.eclipse.ui.examples.buildmonitor; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResourceChangeEvent; +import org.eclipse.core.resources.IResourceChangeListener; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.ILog; + +/** + * Listens to workspace build events and per-builder build events, and records + * a session tree that the view can display. + *

    + * One {@link Session} is created for each workspace-level PRE_BUILD / POST_BUILD + * pair. Inside a session, per-builder events are attributed to their + * {@link ProjectEntry} and {@link BuilderEntry}. To match PRE / POST events + * correctly under parallel builds, each thread uses its own stack of + * in-progress builder entries; a given builder runs synchronously on one + * thread, so push-on-PRE / pop-on-POST is race-free. + */ +public final class BuildRecorder { + + public interface Listener { + void sessionsChanged(); + } + + private static final int MAX_SESSIONS = 50; + + private final int mask = IResourceChangeEvent.PRE_BUILD | IResourceChangeEvent.POST_BUILD + | IResourceChangeEvent.PRE_PROJECT_BUILD | IResourceChangeEvent.POST_PROJECT_BUILD; + + private final IResourceChangeListener eventListener = this::onResourceChangeEvent; + + private final List sessions = Collections.synchronizedList(new ArrayList<>()); + private final List listeners = new CopyOnWriteArrayList<>(); + + private final ThreadLocal> threadStack = ThreadLocal.withInitial(ArrayDeque::new); + + private volatile Session currentSession; + + public void start() { + ResourcesPlugin.getWorkspace().addResourceChangeListener(eventListener, mask); + } + + public void stop() { + ResourcesPlugin.getWorkspace().removeResourceChangeListener(eventListener); + } + + public void addListener(Listener listener) { + listeners.add(listener); + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + public List getSessions() { + synchronized (sessions) { + return new ArrayList<>(sessions); + } + } + + public void clear() { + synchronized (sessions) { + sessions.clear(); + } + fireChanged(); + } + + private void onResourceChangeEvent(IResourceChangeEvent event) { + switch (event.getType()) { + case IResourceChangeEvent.PRE_BUILD -> onWorkspacePreBuild(event); + case IResourceChangeEvent.POST_BUILD -> onWorkspacePostBuild(event); + case IResourceChangeEvent.PRE_PROJECT_BUILD -> onProjectPreBuild(event); + case IResourceChangeEvent.POST_PROJECT_BUILD -> onProjectPostBuild(event); + default -> { /* ignore */ } + } + } + + private void onWorkspacePreBuild(IResourceChangeEvent event) { + Session session = new Session(System.nanoTime(), Instant.now(), event.getBuildKind()); + currentSession = session; + } + + private void onWorkspacePostBuild(IResourceChangeEvent event) { + Session session = currentSession; + if (session == null) { + return; + } + session.finish(System.nanoTime()); + currentSession = null; + synchronized (sessions) { + sessions.add(session); + while (sessions.size() > MAX_SESSIONS) { + sessions.remove(0); + } + } + fireChanged(); + } + + private void onProjectPreBuild(IResourceChangeEvent event) { + Session session = currentSession; + if (session == null || !(event.getSource() instanceof IProject project)) { + return; + } + BuilderEntry entry = new BuilderEntry(event.getBuilderName(), event.getBuildKind(), System.nanoTime()); + session.addBuilderStart(project, entry); + threadStack.get().push(entry); + } + + private void onProjectPostBuild(IResourceChangeEvent event) { + Deque stack = threadStack.get(); + BuilderEntry entry = stack.pollFirst(); + if (entry == null) { + return; + } + entry.finish(System.nanoTime()); + Session session = currentSession; + if (session != null && event.getSource() instanceof IProject project) { + session.noteBuilderEnd(project, entry); + } + } + + private void fireChanged() { + for (Listener l : listeners) { + try { + l.sessionsChanged(); + } catch (RuntimeException e) { + // a broken listener must not poison the notification, but log it + ILog.get().error("Build Monitor listener threw while handling sessionsChanged", e); //$NON-NLS-1$ + } + } + } + + public static final class Session { + private final long startNanos; + private final Instant startedAt; + private final int buildKind; + private final Map projects = Collections.synchronizedMap(new LinkedHashMap<>()); + private volatile long endNanos = -1; + + Session(long startNanos, Instant startedAt, int buildKind) { + this.startNanos = startNanos; + this.startedAt = startedAt; + this.buildKind = buildKind; + } + + void addBuilderStart(IProject project, BuilderEntry entry) { + projects.computeIfAbsent(project, ProjectEntry::new).add(entry, startNanos); + } + + void noteBuilderEnd(IProject project, BuilderEntry entry) { + ProjectEntry p = projects.get(project); + if (p != null) { + p.noteEnd(entry); + } + } + + void finish(long endNanos) { + this.endNanos = endNanos; + } + + public Instant getStartedAt() { + return startedAt; + } + + public int getBuildKind() { + return buildKind; + } + + public long getDurationNanos() { + return endNanos < 0 ? System.nanoTime() - startNanos : endNanos - startNanos; + } + + public long getStartNanos() { + return startNanos; + } + + public List getProjects() { + synchronized (projects) { + return new ArrayList<>(projects.values()); + } + } + } + + public static final class ProjectEntry { + private final IProject project; + private final List builders = Collections.synchronizedList(new ArrayList<>()); + private volatile long lastEndNanos = Long.MIN_VALUE; + private volatile long sessionStartNanos = -1; + + ProjectEntry(IProject project) { + this.project = project; + } + + synchronized void add(BuilderEntry entry, long sessionStart) { + if (sessionStartNanos < 0) { + sessionStartNanos = sessionStart; + } + builders.add(entry); + } + + synchronized void noteEnd(BuilderEntry entry) { + if (entry.getEndNanos() > lastEndNanos) { + lastEndNanos = entry.getEndNanos(); + } + } + + public IProject getProject() { + return project; + } + + public List getBuilders() { + synchronized (builders) { + return new ArrayList<>(builders); + } + } + + /** + * Pure build time: sum of each builder's (end - start). + */ + public long getPureBuildTimeNanos() { + long sum = 0; + synchronized (builders) { + for (BuilderEntry b : builders) { + sum += b.getDurationNanos(); + } + } + return sum; + } + + /** + * Time until ready: from the start of the enclosing workspace build to + * the moment this project's last builder finished. + */ + public long getTimeUntilReadyNanos() { + if (lastEndNanos == Long.MIN_VALUE) { + return 0; + } + return lastEndNanos - sessionStartNanos; + } + } + + public static final class BuilderEntry { + private final String builderName; + private final int buildKind; + private final long startNanos; + private volatile long endNanos = -1; + + BuilderEntry(String builderName, int buildKind, long startNanos) { + this.builderName = builderName; + this.buildKind = buildKind; + this.startNanos = startNanos; + } + + void finish(long endNanos) { + this.endNanos = endNanos; + } + + public String getBuilderName() { + return builderName; + } + + public int getBuildKind() { + return buildKind; + } + + public long getStartNanos() { + return startNanos; + } + + public long getEndNanos() { + return endNanos; + } + + public long getDurationNanos() { + return endNanos < 0 ? 0 : endNanos - startNanos; + } + } +} diff --git a/resources/examples/pom.xml b/resources/examples/pom.xml index b113229b8f9..5452faa876f 100644 --- a/resources/examples/pom.xml +++ b/resources/examples/pom.xml @@ -25,5 +25,6 @@ org.eclipse.ui.examples.filesystem + org.eclipse.ui.examples.buildmonitor diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/builders/ProjectBuildEventTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/builders/ProjectBuildEventTest.java new file mode 100644 index 00000000000..8ec50040079 --- /dev/null +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/builders/ProjectBuildEventTest.java @@ -0,0 +1,182 @@ +/******************************************************************************* + * Copyright (c) 2026 Vogella GmbH 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 + *******************************************************************************/ +package org.eclipse.core.tests.internal.builders; + +import static org.eclipse.core.resources.ResourcesPlugin.getWorkspace; +import static org.eclipse.core.tests.resources.ResourceTestUtil.createTestMonitor; +import static org.eclipse.core.tests.resources.ResourceTestUtil.setAutoBuilding; +import static org.eclipse.core.tests.resources.ResourceTestUtil.updateProjectDescription; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResourceChangeEvent; +import org.eclipse.core.resources.IResourceChangeListener; +import org.eclipse.core.resources.IncrementalProjectBuilder; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.tests.resources.util.WorkspaceResetExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests the PRE_PROJECT_BUILD and POST_PROJECT_BUILD events. In particular it + * asserts that events fire even when a builder is short-circuited by the + * needsBuild check (e.g. an incremental build after Project > Clean where no + * source changed). + */ +@ExtendWith(WorkspaceResetExtension.class) +public class ProjectBuildEventTest { + + private record Record(int type, Object source, String builderName, int buildKind) { + } + + private final List events = new CopyOnWriteArrayList<>(); + private final IResourceChangeListener listener = event -> events.add( + new Record(event.getType(), event.getSource(), event.getBuilderName(), event.getBuildKind())); + + @BeforeEach + public void setUp() { + int mask = IResourceChangeEvent.PRE_BUILD | IResourceChangeEvent.POST_BUILD + | IResourceChangeEvent.PRE_PROJECT_BUILD | IResourceChangeEvent.POST_PROJECT_BUILD; + getWorkspace().addResourceChangeListener(listener, mask); + } + + @AfterEach + public void tearDown() { + getWorkspace().removeResourceChangeListener(listener); + } + + @Test + public void testPerBuilderEventsFireOnFullBuild() throws CoreException { + IProject project = createProjectWithBuilder("FULL"); + + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + List perBuilder = filter(IResourceChangeEvent.PRE_PROJECT_BUILD, + IResourceChangeEvent.POST_PROJECT_BUILD); + assertEquals(2, perBuilder.size(), "expected one PRE/POST_PROJECT_BUILD pair, got events: " + events); + + Record pre = perBuilder.get(0); + Record post = perBuilder.get(1); + assertEquals(IResourceChangeEvent.PRE_PROJECT_BUILD, pre.type()); + assertEquals(IResourceChangeEvent.POST_PROJECT_BUILD, post.type()); + assertEquals(project, pre.source()); + assertEquals(project, post.source()); + assertEquals(DeltaVerifierBuilder.BUILDER_NAME, pre.builderName()); + assertEquals(DeltaVerifierBuilder.BUILDER_NAME, post.builderName()); + assertEquals(IncrementalProjectBuilder.FULL_BUILD, pre.buildKind()); + assertEquals(IncrementalProjectBuilder.FULL_BUILD, post.buildKind()); + } + + @Test + public void testPerBuilderEventsFireOnCleanBuild() throws CoreException { + createProjectWithBuilder("CLEAN"); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.CLEAN_BUILD, createTestMonitor()); + List perBuilder = filter(IResourceChangeEvent.PRE_PROJECT_BUILD, + IResourceChangeEvent.POST_PROJECT_BUILD); + assertEquals(2, perBuilder.size(), "CLEAN should fire one PRE/POST_PROJECT_BUILD pair, got: " + events); + assertEquals(IncrementalProjectBuilder.CLEAN_BUILD, perBuilder.get(0).buildKind()); + assertEquals(IncrementalProjectBuilder.CLEAN_BUILD, perBuilder.get(1).buildKind()); + } + + @Test + public void testPerBuilderEventsFireOnIncrementalBuildWithNoDelta() throws CoreException { + createProjectWithBuilder("INC-NO-DELTA"); + // Prime: run a full build so the builder has a last-built tree. + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + // Second incremental build without any intervening source change. The + // builder should be considered but short-circuited by needsBuild. Events + // must still fire so the Build Monitor view can render the session. + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.INCREMENTAL_BUILD, createTestMonitor()); + List perBuilder = filter(IResourceChangeEvent.PRE_PROJECT_BUILD, + IResourceChangeEvent.POST_PROJECT_BUILD); + assertEquals(2, perBuilder.size(), + "INCREMENTAL with no delta must still fire PRE/POST_PROJECT_BUILD, got: " + events); + } + + @Test + public void testPerBuilderEventsNestedInsideWorkspaceBuild() throws CoreException { + createProjectWithBuilder("NESTED"); + + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + List types = new ArrayList<>(); + for (Record r : events) { + types.add(r.type()); + } + int preBuild = types.indexOf(IResourceChangeEvent.PRE_BUILD); + int postBuild = types.indexOf(IResourceChangeEvent.POST_BUILD); + int prePrj = types.indexOf(IResourceChangeEvent.PRE_PROJECT_BUILD); + int postPrj = types.indexOf(IResourceChangeEvent.POST_PROJECT_BUILD); + assertTrue(preBuild >= 0 && postBuild > preBuild, "workspace PRE/POST_BUILD must be present and ordered: " + events); + assertTrue(prePrj > preBuild && prePrj < postBuild, + "PRE_PROJECT_BUILD must be nested inside workspace build: " + events); + assertTrue(postPrj > prePrj && postPrj < postBuild, + "POST_PROJECT_BUILD must come after its PRE and before workspace POST_BUILD: " + events); + } + + @Test + public void testBuilderNameOnlyPopulatedForProjectBuildEvents() throws CoreException { + createProjectWithBuilder("NAME"); + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + boolean sawProjectBuildWithName = false; + for (Record r : events) { + if (r.type() == IResourceChangeEvent.PRE_PROJECT_BUILD + || r.type() == IResourceChangeEvent.POST_PROJECT_BUILD) { + assertNotNull(r.builderName(), "per-builder event must carry builder name"); + sawProjectBuildWithName = true; + } else { + assertNull(r.builderName(), "workspace-level event must not carry builder name, got: " + r); + } + } + assertTrue(sawProjectBuildWithName, "expected at least one per-builder event: " + events); + } + + private IProject createProjectWithBuilder(String tag) throws CoreException { + IProject project = getWorkspace().getRoot().getProject("PROJECT-" + tag); + setAutoBuilding(false); + project.create(createTestMonitor()); + project.open(createTestMonitor()); + updateProjectDescription(project).addingCommand(DeltaVerifierBuilder.BUILDER_NAME) + .withTestBuilderId("Builder-" + tag).apply(); + return project; + } + + private List filter(int... types) { + List out = new ArrayList<>(); + for (Record r : events) { + for (int t : types) { + if (r.type() == t) { + out.add(r); + break; + } + } + } + return out; + } +}