From b487076bac6b0a6609bcc73b6f9ef5f58df2cf6b Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Thu, 13 Aug 2026 12:08:02 +0200 Subject: [PATCH] Support POJO handlers in the org.eclipse.ui.handlers extension point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler contributed via the class attribute had to implement IHandler. Anything else failed the cast in HandlerProxy with a logged ClassCastException and left the proxy permanently disabled. Contributions that do not implement IHandler are now wrapped in an adapter that dispatches to their @Execute and @CanExecute methods through dependency injection, the same mechanism the E4 application model already uses for its handlers. Field and method injection plus @PostConstruct work as well. The methods run in the active part context, as legacy enablement refreshes pass the window-level evaluation state and E4 handlers resolve the active leaf. Instantiation still goes through createExecutableExtension, so IExecutableExtension and the form keep working unchanged, and handlers implementing IHandler take the previous path untouched. The same path serves the defaultHandler attribute of org.eclipse.ui.commands; QuitHandler is migrated as the first such POJO. Assisted-by: multiple AI agents and layers of automated tooling 🤖 --- .../ui/internal/handlers/HandlerProxy.java | 21 +- .../internal/handlers/PojoHandlerAdapter.java | 191 ++++++++++++++++++ .../ui/internal/handlers/QuitHandler.java | 20 +- .../ui/tests/commands/CommandsTestSuite.java | 3 +- .../ui/tests/commands/PojoHandler.java | 75 +++++++ .../ui/tests/commands/PojoHandlerTest.java | 143 +++++++++++++ tests/org.eclipse.ui.tests/plugin.xml | 20 ++ 7 files changed, 458 insertions(+), 15 deletions(-) create mode 100644 bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/PojoHandlerAdapter.java create mode 100644 tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandler.java create mode 100644 tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandlerTest.java diff --git a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/HandlerProxy.java b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/HandlerProxy.java index 22b023a4811..3c52990afdd 100644 --- a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/HandlerProxy.java +++ b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/HandlerProxy.java @@ -33,6 +33,8 @@ import org.eclipse.core.runtime.IConfigurationElement; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; +import org.eclipse.e4.core.contexts.IEclipseContext; +import org.eclipse.e4.core.di.InjectionException; import org.eclipse.jface.dialogs.MessageDialog; import org.eclipse.jface.util.IPropertyChangeListener; import org.eclipse.ui.PlatformUI; @@ -334,7 +336,7 @@ private boolean loadHandler() { // Load the handler. try { if (configurationElement != null) { - handler = (IHandler) configurationElement.createExecutableExtension(handlerAttributeName); + handler = adapt(configurationElement.createExecutableExtension(handlerAttributeName)); handler.addHandlerListener(getHandlerListener()); if (handler instanceof IObjectWithState) { for (String id : getStateIds()) { @@ -346,8 +348,9 @@ private boolean loadHandler() { return true; } - } catch (final ClassCastException e) { - final String message = "The proxied handler was the wrong class"; //$NON-NLS-1$ + } catch (final InjectionException e) { + final String message = "The proxied handler '" //$NON-NLS-1$ + + getConfigurationElementAttribute() + "' could not be injected"; //$NON-NLS-1$ final IStatus status = new Status(IStatus.ERROR, WorkbenchPlugin.PI_WORKBENCH, 0, message, e); WorkbenchPlugin.log(message, status); configurationElement = null; @@ -367,6 +370,18 @@ private boolean loadHandler() { return true; } + /** + * Wraps handler contributions that do not implement {@link IHandler} so that + * their {@code @Execute} and {@code @CanExecute} methods are dispatched through + * dependency injection. + */ + private static IHandler adapt(Object contribution) { + if (contribution instanceof IHandler handler) { + return handler; + } + return new PojoHandlerAdapter(contribution, PlatformUI.getWorkbench().getService(IEclipseContext.class)); + } + private IHandlerListener getHandlerListener() { if (handlerListener == null) { handlerListener = handlerEvent -> fireHandlerChanged(new HandlerEvent(HandlerProxy.this, diff --git a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/PojoHandlerAdapter.java b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/PojoHandlerAdapter.java new file mode 100644 index 00000000000..edb8b76d538 --- /dev/null +++ b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/PojoHandlerAdapter.java @@ -0,0 +1,191 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ + +package org.eclipse.ui.internal.handlers; + +import java.util.Map; +import java.util.Map.Entry; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.AbstractParameterValueConverter; +import org.eclipse.core.commands.Command; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.core.commands.NotHandledException; +import org.eclipse.core.commands.ParameterType; +import org.eclipse.core.commands.ParameterValueConversionException; +import org.eclipse.core.commands.ParameterizedCommand; +import org.eclipse.core.commands.common.NotDefinedException; +import org.eclipse.core.expressions.IEvaluationContext; +import org.eclipse.e4.core.commands.internal.HandlerServiceImpl; +import org.eclipse.e4.core.contexts.ContextInjectionFactory; +import org.eclipse.e4.core.contexts.EclipseContextFactory; +import org.eclipse.e4.core.contexts.IEclipseContext; +import org.eclipse.e4.core.di.InjectionException; +import org.eclipse.e4.core.di.annotations.CanExecute; +import org.eclipse.e4.core.di.annotations.Execute; +import org.eclipse.swt.widgets.Event; +import org.eclipse.ui.internal.WorkbenchPlugin; + +/** + * Adapts a handler contribution that does not implement + * {@link org.eclipse.core.commands.IHandler} to the legacy handler API by + * dispatching to its {@link Execute} and {@link CanExecute} methods through + * dependency injection. + */ +class PojoHandlerAdapter extends AbstractHandler { + + private static final Object MISSING_EXECUTE = new Object(); + + private final Object handler; + + /** The context the handler was injected from, may be null. */ + private final IEclipseContext injectionContext; + + /** + * Injects the given handler and prepares it for dispatch. + * + * @throws InjectionException if the handler cannot be injected + */ + PojoHandlerAdapter(Object handler, IEclipseContext injectionContext) { + this.handler = handler; + this.injectionContext = injectionContext; + if (injectionContext != null) { + ContextInjectionFactory.inject(handler, injectionContext); + } + } + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + IEclipseContext executionContext = executionContextOf(event.getApplicationContext()); + if (executionContext == null) { + throw new ExecutionException("No IEclipseContext available to execute " + handler.getClass().getName()); //$NON-NLS-1$ + } + IEclipseContext staticContext = EclipseContextFactory.create(); + try { + staticContext.set(HandlerServiceImpl.PARM_MAP, event.getParameters()); + addParameters(event, staticContext); + staticContext.set(ExecutionEvent.class, event); + if (event.getTrigger() instanceof Event trigger) { + staticContext.set(Event.class, trigger); + } + Object result = ContextInjectionFactory.invoke(handler, Execute.class, executionContext, staticContext, + MISSING_EXECUTE); + if (result == MISSING_EXECUTE) { + throw new ExecutionException(handler.getClass().getName() + " handler is missing @Execute", //$NON-NLS-1$ + new NotHandledException(handler.getClass().getName())); + } + return result; + } catch (InjectionException e) { + if (e.getCause() instanceof ExecutionException cause) { + throw cause; + } + throw new ExecutionException("Error executing " + handler.getClass().getName(), e); //$NON-NLS-1$ + } finally { + staticContext.dispose(); + } + } + + @Override + public void setEnabled(Object evaluationContext) { + IEclipseContext executionContext = executionContextOf(evaluationContext); + if (executionContext == null) { + return; + } + IEclipseContext staticContext = EclipseContextFactory.create(); + try { + Object result = ContextInjectionFactory.invoke(handler, CanExecute.class, executionContext, staticContext, + Boolean.TRUE); + // a @CanExecute that does not return boolean leaves enablement untouched + if (result instanceof Boolean enabled) { + setBaseEnabled(enabled.booleanValue()); + } + } catch (InjectionException e) { + WorkbenchPlugin.log("Error while evaluating @CanExecute of " + handler.getClass().getName(), e); //$NON-NLS-1$ + setBaseEnabled(false); + } finally { + staticContext.dispose(); + } + } + + private static void addParameters(ExecutionEvent event, IEclipseContext staticContext) { + Command command = event.getCommand(); + Map parameters = event.getParameters(); + if (command == null || parameters == null) { + return; + } + for (Entry parameter : parameters.entrySet()) { + if (parameter.getKey() instanceof String parameterId) { + staticContext.set(parameterId, convertParameterValue(command, parameterId, parameter.getValue())); + } + } + ParameterizedCommand parameterizedCommand = ParameterizedCommand.generateCommand(command, parameters); + if (parameterizedCommand != null) { + staticContext.set(ParameterizedCommand.class, parameterizedCommand); + } + } + + private static Object convertParameterValue(Command command, String parameterId, Object value) { + if (value instanceof String stringValue) { + try { + ParameterType parameterType = command.getParameterType(parameterId); + if (parameterType != null) { + AbstractParameterValueConverter converter = parameterType.getValueConverter(); + if (converter != null) { + return converter.convertToObject(stringValue); + } + } + } catch (NotDefinedException | ParameterValueConversionException e) { + return stringValue; + } + } + return value; + } + + /** + * Resolves the context to run the handler in: the active leaf below the + * {@link IEclipseContext} the given evaluation object was created from, as the + * E4 handler service does, since legacy enablement updates pass the window + * state rather than the active part. + */ + private IEclipseContext executionContextOf(Object evaluationObject) { + IEclipseContext context = null; + if (evaluationObject instanceof IEclipseContext eclipseContext) { + context = eclipseContext; + } else if (evaluationObject instanceof IEvaluationContext evaluationContext + && evaluationContext.getVariable(IEclipseContext.class.getName()) instanceof IEclipseContext eclipseContext) { + context = eclipseContext; + } else { + context = injectionContext; + } + return context == null ? null : context.getActiveLeaf(); + } + + @Override + public void dispose() { + if (injectionContext != null) { + try { + ContextInjectionFactory.uninject(handler, injectionContext); + } catch (InjectionException e) { + WorkbenchPlugin.log("Error while uninjecting " + handler.getClass().getName(), e); //$NON-NLS-1$ + } + } + super.dispose(); + } + + @Override + public String toString() { + return handler.toString(); + } +} diff --git a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/QuitHandler.java b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/QuitHandler.java index abb38298124..61f5f1dbd9d 100644 --- a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/QuitHandler.java +++ b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/handlers/QuitHandler.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2007, 2015 IBM Corporation and others. + * Copyright (c) 2007, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -14,12 +14,13 @@ package org.eclipse.ui.internal.handlers; -import org.eclipse.core.commands.AbstractHandler; -import org.eclipse.core.commands.ExecutionEvent; -import org.eclipse.core.expressions.IEvaluationContext; +import org.eclipse.e4.core.di.annotations.Execute; +import org.eclipse.e4.core.di.annotations.Optional; import org.eclipse.swt.widgets.Display; import org.eclipse.ui.IWorkbench; +import jakarta.inject.Named; + /** * Exit the workbench. Normal invocation calls {@link IWorkbench#close()}, which * typically doesn't prompt the user before exiting. @@ -30,19 +31,16 @@ * * @since 3.4 */ -public class QuitHandler extends AbstractHandler { +public class QuitHandler { private static final String COMMAND_PARAMETER_ID_MAY_PROMPT = "mayPrompt"; //$NON-NLS-1$ private static final String TRUE = "true"; //$NON-NLS-1$ - @Override - public Object execute(ExecutionEvent event) { - IEvaluationContext context = (IEvaluationContext) event.getApplicationContext(); - IWorkbench workbench = (IWorkbench) context.getVariable(IWorkbench.class.getName()); - if (TRUE.equals(event.getParameter(COMMAND_PARAMETER_ID_MAY_PROMPT))) { + @Execute + public void execute(IWorkbench workbench, @Optional @Named(COMMAND_PARAMETER_ID_MAY_PROMPT) String mayPrompt) { + if (TRUE.equals(mayPrompt)) { workbench.getDisplay().close(); } else { workbench.close(); } - return null; } } diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/CommandsTestSuite.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/CommandsTestSuite.java index 84a94dd30a9..9afeadb5c6b 100644 --- a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/CommandsTestSuite.java +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/CommandsTestSuite.java @@ -39,7 +39,8 @@ ToggleStateTest.class, RadioStateTest.class, WorkbenchStateTest.class, - E4CommandImageTest.class + E4CommandImageTest.class, + PojoHandlerTest.class }) public final class CommandsTestSuite { } diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandler.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandler.java new file mode 100644 index 00000000000..15871594643 --- /dev/null +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandler.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.tests.commands; + +import org.eclipse.e4.core.contexts.IEclipseContext; +import org.eclipse.e4.core.di.annotations.CanExecute; +import org.eclipse.e4.core.di.annotations.Execute; +import org.eclipse.e4.core.di.annotations.Optional; +import org.eclipse.e4.ui.model.application.ui.basic.MPart; + +import jakarta.inject.Inject; +import jakarta.inject.Named; + +/** + * A handler contributed to org.eclipse.ui.handlers that implements + * neither IHandler nor any other platform interface. + */ +public class PojoHandler { + + public static final String PARAMETER_ID = "org.eclipse.ui.tests.commands.pojoHandler.value"; //$NON-NLS-1$ + + public static boolean executed; + + public static String parameterValue; + + public static boolean canExecute = true; + + public static IEclipseContext injectedContext; + + public static MPart canExecutePart; + + public static MPart executePart; + + public static IEclipseContext executeContext; + + static void reset() { + executed = false; + parameterValue = null; + canExecute = true; + canExecutePart = null; + executePart = null; + executeContext = null; + } + + @Inject + void setContext(IEclipseContext context) { + injectedContext = context; + } + + @CanExecute + public boolean canExecute(@Optional MPart part) { + canExecutePart = part; + return canExecute; + } + + @Execute + public Object execute(@Optional @Named(PARAMETER_ID) String value, @Optional MPart part, IEclipseContext context) { + executePart = part; + executeContext = context; + executed = true; + parameterValue = value; + return "executed"; //$NON-NLS-1$ + } +} diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandlerTest.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandlerTest.java new file mode 100644 index 00000000000..4e267a85ef8 --- /dev/null +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/commands/PojoHandlerTest.java @@ -0,0 +1,143 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.tests.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.core.commands.Command; +import org.eclipse.core.commands.NotEnabledException; +import org.eclipse.core.commands.ParameterizedCommand; +import org.eclipse.core.commands.Parameterization; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.IViewPart; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.e4.ui.model.application.ui.basic.MPart; +import static org.junit.jupiter.api.Assertions.assertSame; +import org.eclipse.ui.commands.ICommandService; +import org.eclipse.ui.handlers.IHandlerService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests that a handler contributed to org.eclipse.ui.handlers is + * dispatched through dependency injection when it does not implement + * IHandler. + */ +public class PojoHandlerTest { + + private static final String COMMAND_ID = "org.eclipse.ui.tests.commands.pojoHandler"; + + private IHandlerService handlerService; + + private ICommandService commandService; + + @BeforeEach + public void setUp() { + handlerService = PlatformUI.getWorkbench().getService(IHandlerService.class); + commandService = PlatformUI.getWorkbench().getService(ICommandService.class); + PojoHandler.reset(); + } + + @AfterEach + public void tearDown() { + PojoHandler.reset(); + } + + @Test + public void executeDispatchesToExecuteAnnotatedMethod() throws Exception { + Object result = handlerService.executeCommand(COMMAND_ID, null); + + assertTrue(PojoHandler.executed, "@Execute method was not called"); + assertEquals("executed", result, "the return value of @Execute was not passed back"); + } + + @Test + public void canExecuteControlsEnablement() throws Exception { + PojoHandler.canExecute = false; + + assertThrows(NotEnabledException.class, () -> handlerService.executeCommand(COMMAND_ID, null)); + assertFalse(PojoHandler.executed, "@Execute was called although @CanExecute returned false"); + + PojoHandler.canExecute = true; + handlerService.executeCommand(COMMAND_ID, null); + assertTrue(PojoHandler.executed, "@Execute was not called after @CanExecute turned true"); + } + + @Test + public void handlerIsInjected() throws Exception { + handlerService.executeCommand(COMMAND_ID, null); + + assertNotNull(PojoHandler.injectedContext, "the handler was not injected"); + } + + @Test + public void commandParameterIsInjectedByName() throws Exception { + Command command = commandService.getCommand(COMMAND_ID); + Parameterization parameter = new Parameterization(command.getParameter(PojoHandler.PARAMETER_ID), "42"); + ParameterizedCommand parameterizedCommand = new ParameterizedCommand(command, + new Parameterization[] { parameter }); + + handlerService.executeCommand(parameterizedCommand, null); + + assertTrue(PojoHandler.executed, "@Execute method was not called"); + assertEquals("42", PojoHandler.parameterValue, "the command parameter was not injected by its id"); + } + + @Test + public void commandIsHandled() throws Exception { + Command command = commandService.getCommand(COMMAND_ID); + + assertTrue(command.isDefined(), "the test command is not defined"); + handlerService.executeCommand(COMMAND_ID, null); + assertTrue(command.isHandled(), "the command is not handled by the POJO"); + } + + @Test + public void executeRunsInActivePartContext() throws Exception { + IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart view = page.showView("org.eclipse.ui.views.ProblemView"); + try { + MPart activePart = view.getSite().getService(MPart.class); + assertNotNull(activePart); + + handlerService.executeCommand(COMMAND_ID, null); + + assertSame(activePart, PojoHandler.executePart, "@Execute did not see the active part"); + assertSame(activePart.getContext(), PojoHandler.executeContext, "@Execute did not run in the part context"); + assertSame(activePart, PojoHandler.canExecutePart, "@CanExecute during execute did not see the active part"); + } finally { + page.hideView(view); + } + } + + @Test + public void enablementRunsInActivePartContext() throws Exception { + IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + IViewPart view = page.showView("org.eclipse.ui.views.ProblemView"); + try { + MPart activePart = view.getSite().getService(MPart.class); + // what CommandContributionItem and CommandAction do to refresh enablement + commandService.getCommand(COMMAND_ID).setEnabled(handlerService.getCurrentState()); + + assertSame(activePart, PojoHandler.canExecutePart, "@CanExecute did not see the active part"); + } finally { + page.hideView(view); + } + } +} diff --git a/tests/org.eclipse.ui.tests/plugin.xml b/tests/org.eclipse.ui.tests/plugin.xml index 5dbc053ba98..234bf15070e 100644 --- a/tests/org.eclipse.ui.tests/plugin.xml +++ b/tests/org.eclipse.ui.tests/plugin.xml @@ -4962,4 +4962,24 @@ point="org.eclipse.core.resources.markers"> + + + + + + + + + + +