This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 54eb878da8 ability to install multiple marketplace plugins, fixes  
#7799 (#7800)
54eb878da8 is described below

commit 54eb878da850fa20b10bac6e6b6d183a7e6cf518
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 6 15:08:26 2026 +0200

    ability to install multiple marketplace plugins, fixes  #7799 (#7800)
---
 assemblies/client/src/assembly/assembly.xml        |   2 -
 assemblies/plugins/pom.xml                         | 116 +++----
 .../hop/core/extension/ExtensionPointMap.java      |  17 +
 .../hop/core/extension/HopExtensionPoint.java      |   8 +
 .../hop/core/extension/ExtensionPointMapTest.java  |  13 +
 .../ROOT/pages/hop-tools/hop-marketplace.adoc      |   9 +-
 .../metadata/SerializableMetadataProvider.java     |  31 +-
 .../metadata/SerializableMetadataProviderTest.java | 106 ++++++
 .../marketplace/command/MarketplaceCommand.java    | 128 +++++--
 .../hop/marketplace/gui/MarketplaceDialog.java     | 380 ++++++++++++++++-----
 .../xp/SearchMarketplaceExtensionPoint.java        |  58 ++++
 .../gui/messages/messages_en_US.properties         |   9 +
 .../command/InstallCommandParsingTest.java         |  68 ++++
 plugins/tech/azure/pom.xml                         |  11 +
 .../transforms/missing/MissingPipelineDialog.java  |  61 ++++
 .../actions/missing/MissingActionDialog.java       |  61 ++++
 .../missing/messages/messages_en_US.properties     |   3 +
 .../missing/messages/messages_en_US.properties     |   3 +
 18 files changed, 882 insertions(+), 202 deletions(-)

diff --git a/assemblies/client/src/assembly/assembly.xml 
b/assemblies/client/src/assembly/assembly.xml
index c5b6534a64..d25f356db3 100644
--- a/assemblies/client/src/assembly/assembly.xml
+++ b/assemblies/client/src/assembly/assembly.xml
@@ -40,8 +40,6 @@
                 <include>org.apache.hop:hop-core:zip</include>
                 <include>org.apache.hop:hop-engine:zip</include>
                 <include>org.apache.hop:hop-ui:zip</include>
-                <!-- Beam is a marketplace plugin; bundled only when the 
dependency is
-                     present (-Pbeam / -Pfull). It brings its own lib/core 
libs and lib/spark-client. -->
                 <include>org.apache.hop:hop-engines-beam:zip</include>
                 <include>org.apache.hop:hop-ui-rcp:zip</include>
             </includes>
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 87eb032708..97ce2f803d 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -618,7 +618,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-engines-spark -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-misc-async</artifactId>
@@ -631,7 +630,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-misc-documentation -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-misc-execution-database</artifactId>
@@ -644,8 +642,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-misc-import -->
-        <!-- marketplace optional: hop-misc-mail -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-misc-mail</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-misc-marketplace</artifactId>
@@ -664,14 +666,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-misc-py4j -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-misc-reflection</artifactId>
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-misc-rest -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-misc-rest</artifactId>
@@ -696,23 +696,24 @@
             <version>2.19.0-SNAPSHOT</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-tech-arrow -->
-        <!-- marketplace optional: hop-tech-avro -->
-        <!-- marketplace optional: hop-tech-aws -->
-        <!-- marketplace optional: hop-tech-azure -->
-        <!-- marketplace optional: hop-tech-cassandra -->
-        <!-- marketplace optional: hop-tech-databricks -->
-        <!-- marketplace optional: hop-tech-dropbox -->
-        <!-- marketplace optional: hop-tech-elastic -->
-        <!-- marketplace optional: hop-tech-minio -->
-        <!-- marketplace optional: hop-tech-mongodb -->
-        <!-- marketplace optional: hop-tech-neo4j -->
-        <!-- marketplace optional: hop-tech-opensearch -->
-        <!-- marketplace optional: hop-tech-parquet -->
-        <!-- marketplace optional: hop-tech-redis -->
-        <!-- marketplace optional: hop-tech-salesforce -->
-        <!-- Also listed in the marketplace catalog (optional-plugins.yaml) so 
slim
-             distributions can install it, but it ships with the client. -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-tech-aws</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-tech-azure</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-tech-google</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-tech-sftp</artifactId>
@@ -917,7 +918,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-detectlanguage -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-detectlastrow</artifactId>
@@ -930,14 +930,18 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-drools -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-dynamicsqlrow</artifactId>
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-edi2xml -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-edi2xml</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-excel</artifactId>
@@ -962,7 +966,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-fake -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-fake</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-fieldschangesequence</artifactId>
@@ -1323,7 +1332,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-script -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-script</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-selectvalues</artifactId>
@@ -1408,7 +1422,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-stanfordnlp -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-streamlookup</artifactId>
@@ -1487,7 +1500,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-tika -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-tokenreplacement</artifactId>
@@ -1524,7 +1536,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-        <!-- marketplace optional: hop-transform-vcard -->
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-webserviceavailable</artifactId>
@@ -1573,7 +1584,6 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
-
     </dependencies>
 
     <!--
@@ -1613,12 +1623,6 @@
                     <version>${project.version}</version>
                     <type>zip</type>
                 </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-misc-mail</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
                 <dependency>
                     <groupId>org.apache.hop</groupId>
                     <artifactId>hop-misc-py4j</artifactId>
@@ -1643,18 +1647,6 @@
                     <version>${project.version}</version>
                     <type>zip</type>
                 </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-tech-aws</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-tech-azure</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
                 <dependency>
                     <groupId>org.apache.hop</groupId>
                     <artifactId>hop-tech-cassandra</artifactId>
@@ -1679,12 +1671,6 @@
                     <version>${project.version}</version>
                     <type>zip</type>
                 </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-tech-google</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
                 <dependency>
                     <groupId>org.apache.hop</groupId>
                     <artifactId>hop-tech-minio</artifactId>
@@ -1739,24 +1725,6 @@
                     <version>${project.version}</version>
                     <type>zip</type>
                 </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-transform-edi2xml</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-transform-fake</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-transform-script</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
                 <dependency>
                     <groupId>org.apache.hop</groupId>
                     <artifactId>hop-transform-stanfordnlp</artifactId>
diff --git 
a/core/src/main/java/org/apache/hop/core/extension/ExtensionPointMap.java 
b/core/src/main/java/org/apache/hop/core/extension/ExtensionPointMap.java
index 41b0cf9ac7..4d27c1fc31 100644
--- a/core/src/main/java/org/apache/hop/core/extension/ExtensionPointMap.java
+++ b/core/src/main/java/org/apache/hop/core/extension/ExtensionPointMap.java
@@ -158,6 +158,23 @@ public class ExtensionPointMap {
     }
   }
 
+  /**
+   * Is anything listening to this extension point id?
+   *
+   * <p>Lets the GUI leave out actions that only an optional plugin can 
perform: the marketplace
+   * button on the missing plugins dialog is hidden when the marketplace 
plugin isn't installed.
+   *
+   * @param id the id of the extension point interface
+   */
+  public boolean isRegistered(String id) {
+    lock.readLock().lock();
+    try {
+      return extensionPointPluginMap.containsRow(id);
+    } finally {
+      lock.readLock().unlock();
+    }
+  }
+
   /**
    * Returns the element in the position (rowId,columnId) of the table
    *
diff --git 
a/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java 
b/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
index c145a789a8..b4c8d2e621 100644
--- a/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
+++ b/core/src/main/java/org/apache/hop/core/extension/HopExtensionPoint.java
@@ -165,6 +165,14 @@ public enum HopExtensionPoint {
   HopGuiGetControlSpaceSortOrderPrefix(
       "Gets a prefix to steer the sort order of variables when using 
CTRL-SPACE.  Defaults range from 900_ to 400_. Set prefixes in 
Map<String,String>"),
 
+  /**
+   * Open the marketplace so a missing plugin can be looked up and installed. 
Payload is the plugin
+   * id to search for (String); blank or null opens the marketplace 
unfiltered. Only listened to
+   * when the marketplace plugin is installed, so callers should check {@link
+   * ExtensionPointMap#isRegistered(String)} before offering it.
+   */
+  HopGuiSearchMarketplace("Open the marketplace, searching for a plugin id 
(String)"),
+
   HopImportStart("Executed at the start of the 'hop-import' command line 
tool"),
   HopImportEnd("Executed at the end of the 'hop-import' command line tool"),
 
diff --git 
a/core/src/test/java/org/apache/hop/core/extension/ExtensionPointMapTest.java 
b/core/src/test/java/org/apache/hop/core/extension/ExtensionPointMapTest.java
index 73fb536dde..a4e4bbb1bc 100644
--- 
a/core/src/test/java/org/apache/hop/core/extension/ExtensionPointMapTest.java
+++ 
b/core/src/test/java/org/apache/hop/core/extension/ExtensionPointMapTest.java
@@ -18,6 +18,8 @@
 package org.apache.hop.core.extension;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
@@ -63,6 +65,17 @@ class ExtensionPointMapTest {
     verify(pluginInterface, never()).loadClass(any(Class.class));
   }
 
+  @Test
+  void isRegisteredTest() {
+    assertFalse(ExtensionPointMap.getInstance().isRegistered(TEST_NAME));
+
+    ExtensionPointMap.getInstance().addExtensionPoint(pluginInterface);
+    assertTrue(ExtensionPointMap.getInstance().isRegistered(TEST_NAME));
+
+    ExtensionPointMap.getInstance().removeExtensionPoint(pluginInterface);
+    assertFalse(ExtensionPointMap.getInstance().isRegistered(TEST_NAME));
+  }
+
   @Test
   void addExtensionPointTest() {
     ExtensionPointMap.getInstance().addExtensionPoint(pluginInterface);
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
index f02b57bf8c..556c1dda09 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-marketplace.adoc
@@ -76,6 +76,7 @@ From the Hop install directory:
 ./hop marketplace install hop-tech-parquet
 ./hop marketplace install hop-engines-spark:2.19.0
 ./hop marketplace install hop-engines-spark:2.19.0 --repo asf
+./hop marketplace install hop-tech-parquet hop-transform-tika datavault
 ./hop marketplace list
 ./hop marketplace query parquet
 ./hop marketplace query "spark engine"
@@ -99,6 +100,11 @@ After install, apply, or uninstall, *restart Hop* so the 
plugin registry reloads
 Coordinates for `install`: `artifactId`, `artifactId:version`, or 
`groupId:artifactId:version`.
 Optional `--repo <id>` forces a single repository (skips the fallback chain).
 
+`install` accepts several coordinates in one command. All of them are resolved 
before the first
+download, so an unknown or ambiguous name fails before anything is fetched. 
Downloads then run one
+after another with a `[2/5]` progress line per plugin; a plugin that fails to 
install does not stop
+the others, and the command exits non-zero when any of them failed.
+
 `query` searches the local optional-plugin catalog (not Maven Central). The 
filter is a
 case-insensitive substring matched against artifact id, name, category, 
description, and install
 path. Omit the filter (or pass an empty string) to list every optional plugin. 
Matches may show
@@ -141,7 +147,8 @@ Use this tab to work with the local optional-plugin catalog:
 ** *Installed* — present on disk with a marketplace install receipt (version 
shown when known)
 ** *Present on disk* — plugin folder exists but no marketplace receipt
 ** *Not installed* — not found under `plugins/`
-* *Install* / *Uninstall* / *Refresh* — act on the selected plugin (uninstall 
requires a marketplace receipt)
+* *Install* / *Uninstall* / *Refresh* — act on the selected plugins (uninstall 
requires a marketplace receipt)
+* Several plugins can be selected at once with ctrl-click, shift-click, or 
ctrl-A, and installed or uninstalled in one go. Installs run one after another 
behind a single progress dialog; a plugin that fails does not stop the others 
and every failure is reported together at the end.
 
 Install uses the *primary* repository first, then other *enabled* repositories 
(see <<marketplace-gui-repositories>>).
 
diff --git 
a/engine/src/main/java/org/apache/hop/core/metadata/SerializableMetadataProvider.java
 
b/engine/src/main/java/org/apache/hop/core/metadata/SerializableMetadataProvider.java
index bf3fb94a7d..d1c0d5e455 100644
--- 
a/engine/src/main/java/org/apache/hop/core/metadata/SerializableMetadataProvider.java
+++ 
b/engine/src/main/java/org/apache/hop/core/metadata/SerializableMetadataProvider.java
@@ -21,7 +21,9 @@ import com.fasterxml.jackson.core.JsonFactory;
 import com.fasterxml.jackson.core.JsonToken;
 import java.io.ByteArrayInputStream;
 import java.nio.charset.StandardCharsets;
+import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.IHopMetadata;
@@ -46,6 +48,10 @@ public class SerializableMetadataProvider extends 
MemoryMetadataProvider
   /**
    * Create a copy of all elements in an existing metadata.
    *
+   * <p>Objects which can't be loaded, typically because the plugin they 
reference isn't installed
+   * (a Beam or Spark pipeline run configuration in a plain client, ...), are 
logged and skipped.
+   * They shouldn't keep a pipeline or workflow which doesn't use them from 
running.
+   *
    * @param source the source store to copy over
    */
   public SerializableMetadataProvider(IHopMetadataProvider source) throws 
HopException {
@@ -60,11 +66,34 @@ public class SerializableMetadataProvider extends 
MemoryMetadataProvider
       // Loop over the available objects of the class and copy the information 
over.
       //
       for (String name : sourceSerializer.listObjectNames()) {
-        targetSerializer.save(sourceSerializer.load(name));
+        try {
+          IHopMetadata object = sourceSerializer.load(name);
+          if (object != null) {
+            targetSerializer.save(object);
+          }
+        } catch (Exception e) {
+          logSkippedObject(name, metadataClass, e);
+        }
       }
     }
   }
 
+  private static void logSkippedObject(String name, Class<?> metadataClass, 
Exception e) {
+    String message =
+        "Error copying metadata object '"
+            + name
+            + "' of type "
+            + metadataClass.getSimpleName()
+            + ".  The object is ignored.";
+    if (HopLogStore.isInitialized()) {
+      LogChannel.GENERAL.logError(message, e);
+    } else {
+      // Metadata is also copied before any logging is available.
+      //
+      System.err.println(message + Const.CR + Const.getStackTracker(e));
+    }
+  }
+
   public String toJson() throws HopException {
 
     JSONObject jStore = new JSONObject();
diff --git 
a/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataProviderTest.java
 
b/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataProviderTest.java
index 3f03658013..66b8dc0efe 100644
--- 
a/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataProviderTest.java
+++ 
b/engine/src/test/java/org/apache/hop/core/metadata/SerializableMetadataProviderTest.java
@@ -19,9 +19,14 @@ package org.apache.hop.core.metadata;
 
 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 java.util.List;
+import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.TestUtil;
 import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataSerializer;
 import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
 import org.apache.hop.server.HopServerMeta;
@@ -73,4 +78,105 @@ class SerializableMetadataProviderTest {
     assertEquals(sourceServer2.getUsername(), targetServer2.getUsername());
     assertEquals(sourceServer2.getPassword(), targetServer2.getPassword());
   }
+
+  /**
+   * An object which can't be loaded, for example a pipeline run configuration 
referencing an engine
+   * plugin which isn't installed, shouldn't keep the other objects from being 
copied. Otherwise a
+   * pipeline or workflow which doesn't use that object at all can't even be 
prepared for execution.
+   */
+  @Test
+  void testUnloadableObjectIsSkipped() throws Exception {
+    MemoryMetadataProvider source =
+        new MemoryMetadataProvider() {
+          @Override
+          public <T extends IHopMetadata> IHopMetadataSerializer<T> 
getSerializer(
+              Class<T> managedClass) throws HopException {
+            IHopMetadataSerializer<T> serializer = 
super.getSerializer(managedClass);
+            if (HopServerMeta.class.equals(managedClass)) {
+              return new FailingOnLoadSerializer<>(serializer, "broken");
+            }
+            return serializer;
+          }
+        };
+    IHopMetadataSerializer<HopServerMeta> sourceSerializer =
+        source.getSerializer(HopServerMeta.class);
+    sourceSerializer.save(
+        new HopServerMeta(
+            "server1", "hostname1", "8181", "username1", "password1", null, 
null, null, false));
+    sourceSerializer.save(
+        new HopServerMeta(
+            "broken", "hostname2", "8282", "username2", "password2", null, 
null, null, false));
+
+    SerializableMetadataProvider target = new 
SerializableMetadataProvider(source);
+    IHopMetadataSerializer<HopServerMeta> targetSerializer =
+        target.getSerializer(HopServerMeta.class);
+
+    assertNotNull(targetSerializer.load("server1"));
+    assertNull(targetSerializer.load("broken"));
+
+    // The same needs to hold for the JSON round trip used to ship metadata to 
an engine.
+    //
+    SerializableMetadataProvider roundTripped = new 
SerializableMetadataProvider(target.toJson());
+    
assertNotNull(roundTripped.getSerializer(HopServerMeta.class).load("server1"));
+  }
+
+  /** Delegates everything but throws when one specific object is loaded. */
+  private static class FailingOnLoadSerializer<T extends IHopMetadata>
+      implements IHopMetadataSerializer<T> {
+    private final IHopMetadataSerializer<T> delegate;
+    private final String failingName;
+
+    FailingOnLoadSerializer(IHopMetadataSerializer<T> delegate, String 
failingName) {
+      this.delegate = delegate;
+      this.failingName = failingName;
+    }
+
+    @Override
+    public T load(String objectName) throws HopException {
+      if (failingName.equals(objectName)) {
+        throw new HopException("Unable to find the plugin for object '" + 
objectName + "'");
+      }
+      return delegate.load(objectName);
+    }
+
+    @Override
+    public String getDescription() {
+      return delegate.getDescription();
+    }
+
+    @Override
+    public void save(T object) throws HopException {
+      delegate.save(object);
+    }
+
+    @Override
+    public T delete(String name) throws HopException {
+      return delegate.delete(name);
+    }
+
+    @Override
+    public List<String> listObjectNames() throws HopException {
+      return delegate.listObjectNames();
+    }
+
+    @Override
+    public List<T> loadAll() throws HopException {
+      return delegate.loadAll();
+    }
+
+    @Override
+    public boolean exists(String name) throws HopException {
+      return delegate.exists(name);
+    }
+
+    @Override
+    public Class<T> getManagedClass() {
+      return delegate.getManagedClass();
+    }
+
+    @Override
+    public IHopMetadataProvider getMetadataProvider() {
+      return delegate.getMetadataProvider();
+    }
+  }
 }
diff --git 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
index 074d8fc04a..fa5507f51f 100644
--- 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
+++ 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
@@ -136,16 +136,21 @@ public class MarketplaceCommand implements Runnable, 
IHopCommand, IHasHopMetadat
       mixinStandardHelpOptions = true,
       name = "install",
       description =
-          "Download and install a plugin zip. Short names resolve via the same 
discovery as query"
-              + " (e.g. datavault → hop-datavault:0.4.0-SNAPSHOT from a browse 
repo).")
+          "Download and install one or more plugin zips. Short names resolve 
via the same discovery"
+              + " as query (e.g. datavault → hop-datavault:0.4.0-SNAPSHOT from 
a browse repo)."
+              + " Every coordinate is resolved before the first download, so a 
typo fails before"
+              + " anything is fetched. A plugin that fails to install does not 
stop the others;"
+              + " the command exits non-zero when any of them failed.")
   static class InstallCommand extends MarketplaceSubCommand {
     @Parameters(
         index = "0",
+        arity = "1..*",
         paramLabel = "COORDINATE",
         description =
-            "Short name, artifactId, artifactId:version, or 
groupId:artifactId:version"
-                + " (e.g. datavault, hop-tech-parquet, 
hop-datavault:0.4.0-SNAPSHOT)")
-    private String coordinate;
+            "One or more short names, artifactIds, artifactId:version, or"
+                + " groupId:artifactId:version, separated by spaces"
+                + " (e.g. datavault hop-tech-parquet 
hop-datavault:0.4.0-SNAPSHOT)")
+    private List<String> coordinates;
 
     @Option(
         names = {"--repo"},
@@ -162,44 +167,67 @@ public class MarketplaceCommand implements Runnable, 
IHopCommand, IHasHopMetadat
           throw new HopException("Marketplace is disabled in hop-config.json");
         }
         Path hopHome = HopHome.resolve();
+        PluginInstaller installer = new PluginInstaller(log, hopHome, config);
         // Activate any previously staged plugins first
-        new PluginInstaller(log, hopHome, config).activateAllPending();
-
-        PluginDiscovery.InstallTarget target =
-            PluginDiscovery.resolveInstall(
-                coordinate, config.getGroupId(), 
resolveDefaultVersion(config), config, log);
-        MavenCoordinates gav = target.coordinates();
-        if (StringUtils.isNotBlank(target.preferredRepoId()) && 
StringUtils.isBlank(repoId)) {
-          System.out.println(
-              "Resolved "
-                  + coordinate
-                  + " → "
-                  + gav.gav()
-                  + " (prefer repo '"
-                  + target.preferredRepoId()
-                  + "')");
-        } else if (!coordinate.trim().equals(gav.artifactId()) || 
!coordinate.contains(":")) {
-          System.out.println("Resolved " + coordinate + " → " + gav.gav());
+        installer.activateAllPending();
+
+        // Resolve the whole list before fetching anything: an unknown or 
ambiguous coordinate at
+        // the end of the line should not surface after three plugins have 
already downloaded.
+        List<PluginDiscovery.InstallTarget> targets = new ArrayList<>();
+        for (String coordinate : coordinates) {
+          PluginDiscovery.InstallTarget target =
+              PluginDiscovery.resolveInstall(
+                  coordinate, config.getGroupId(), 
resolveDefaultVersion(config), config, log);
+          printResolution(coordinate, target);
+          targets.add(target);
         }
+
+        List<String> failed = new ArrayList<>();
+        int installed = 0;
         ConsoleInstallListener progress = ConsoleInstallListener.forStdOut();
-        InstallReceipt receipt;
         try {
-          receipt =
-              new PluginInstaller(log, hopHome, config)
-                  .install(gav, true, repoId, target.preferredRepoId(), 
progress);
+          for (int i = 0; i < targets.size(); i++) {
+            PluginDiscovery.InstallTarget target = targets.get(i);
+            MavenCoordinates gav = target.coordinates();
+            // Prints "[2/5] hop-tech-parquet"; silent for a single install.
+            progress.item(gav.artifactId(), i, targets.size());
+            try {
+              InstallReceipt receipt =
+                  installer.install(gav, true, repoId, 
target.preferredRepoId(), progress);
+              installed++;
+              System.out.println(installedMessage(gav, receipt, hopHome, 
targets.size() == 1));
+            } catch (Exception e) {
+              // One plugin failing shouldn't cost the user the rest of the 
batch. Everything that
+              // went wrong is listed at the end, and the exit code still 
reports failure.
+              // Close any half-drawn progress bar so the error starts on its 
own row.
+              progress.complete();
+              failed.add(gav.artifactId());
+              System.err.println("ERROR: failed to install " + gav.gav() + ": 
" + e.getMessage());
+              if (log != null) {
+                log.logError("Marketplace install failed for " + gav.gav(), e);
+              }
+            }
+          }
         } finally {
           // Close off the in-place bar line so what follows starts on a fresh 
row.
           progress.complete();
         }
-        System.out.println(
-            "Plugin "
-                + gav.gav()
-                + " installed under "
-                + hopHome
-                + (receipt.getRepositoryId() != null
-                    ? " from repo '" + receipt.getRepositoryId() + "'"
-                    : "")
-                + ". Restart Hop to load it.");
+
+        if (targets.size() > 1) {
+          System.out.println(
+              installed
+                  + " of "
+                  + targets.size()
+                  + " plugins installed under "
+                  + hopHome
+                  + ". Restart Hop to load them.");
+        }
+        if (!failed.isEmpty()) {
+          throw new CommandLine.ExecutionException(
+              new CommandLine(this), "failed to install: " + String.join(", ", 
failed));
+        }
+      } catch (CommandLine.ExecutionException e) {
+        throw e;
       } catch (Exception e) {
         System.err.println("ERROR: " + e.getMessage());
         if (log != null) {
@@ -209,6 +237,36 @@ public class MarketplaceCommand implements Runnable, 
IHopCommand, IHasHopMetadat
             new CommandLine(this), e.getMessage() == null ? "install failed" : 
e.getMessage(), e);
       }
     }
+
+    /** Report what a short name resolved to, unless the user already typed 
the full coordinate. */
+    private void printResolution(String coordinate, 
PluginDiscovery.InstallTarget target) {
+      MavenCoordinates gav = target.coordinates();
+      if (StringUtils.isNotBlank(target.preferredRepoId()) && 
StringUtils.isBlank(repoId)) {
+        System.out.println(
+            "Resolved "
+                + coordinate
+                + " → "
+                + gav.gav()
+                + " (prefer repo '"
+                + target.preferredRepoId()
+                + "')");
+      } else if (!coordinate.trim().equals(gav.artifactId()) || 
!coordinate.contains(":")) {
+        System.out.println("Resolved " + coordinate + " → " + gav.gav());
+      }
+    }
+
+    private static String installedMessage(
+        MavenCoordinates gav, InstallReceipt receipt, Path hopHome, boolean 
single) {
+      return "Plugin "
+          + gav.gav()
+          + " installed under "
+          + hopHome
+          + (receipt.getRepositoryId() != null
+              ? " from repo '" + receipt.getRepositoryId() + "'"
+              : "")
+          // For a batch the restart hint belongs on the summary line, not on 
every plugin.
+          + (single ? ". Restart Hop to load it." : ".");
+    }
   }
 
   @Command(
diff --git 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceDialog.java
 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceDialog.java
index 0decbf76f3..605594b792 100644
--- 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceDialog.java
+++ 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/gui/MarketplaceDialog.java
@@ -21,8 +21,11 @@ import java.lang.reflect.InvocationTargetException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.IRunnableWithProgress;
@@ -103,9 +106,20 @@ public class MarketplaceDialog extends Dialog {
   private boolean searchPending;
   private long searchRequestedAt;
 
+  /** Pre-filled search text, e.g. the plugin id from the missing plugins 
dialog. */
+  private final String initialSearch;
+
   public MarketplaceDialog(Shell parent) {
+    this(parent, null);
+  }
+
+  /**
+   * @param initialSearch search box content on open, or null/blank to list 
everything
+   */
+  public MarketplaceDialog(Shell parent, String initialSearch) {
     super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE | 
SWT.MAX);
     this.props = PropsUi.getInstance();
+    this.initialSearch = initialSearch;
   }
 
   public void open() {
@@ -181,6 +195,11 @@ public class MarketplaceDialog extends Dialog {
 
     BaseTransformDialog.setSize(shell);
     shell.open();
+    if (StringUtils.isNotBlank(initialSearch) && !wSearch.isDisposed()) {
+      // Selected, so typing replaces the suggested plugin id when it turns up 
nothing.
+      wSearch.setFocus();
+      wSearch.selectAll();
+    }
     Display display = parent.getDisplay();
     while (!shell.isDisposed()) {
       if (!display.readAndDispatch()) {
@@ -242,6 +261,11 @@ public class MarketplaceDialog extends Dialog {
     fdSearch.top = new FormAttachment(wlSearch, 0, SWT.CENTER);
     fdSearch.right = new FormAttachment(wClearSearch, -PropsUi.getMargin());
     wSearch.setLayoutData(fdSearch);
+    // Set before the listener is added: the first refreshTable() below 
already picks this up, a
+    // Modify event here would only schedule a second, identical query.
+    if (StringUtils.isNotBlank(initialSearch)) {
+      wSearch.setText(initialSearch.trim());
+    }
     wSearch.addListener(SWT.Modify, e -> scheduleSearch());
 
     Label wSearchSep = new Label(comp, SWT.HORIZONTAL | SWT.SEPARATOR);
@@ -292,7 +316,9 @@ public class MarketplaceDialog extends Dialog {
         new TableView(
             Variables.getADefaultVariableSpace(),
             comp,
-            SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL,
+            // No SWT.SINGLE: TableView adds SWT.MULTI, so several plugins can 
be installed or
+            // removed in one go.
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.V_SCROLL,
             columns,
             1,
             true,
@@ -482,8 +508,7 @@ public class MarketplaceDialog extends Dialog {
     for (OptionalPluginInfo info : plugins) {
       // Column 0 is the (hidden) index column; data columns are 1-based.
       TableItem item = new TableItem(wTable.table, SWT.NONE);
-      String name = Const.NVL(info.getName(), info.getArtifactId());
-      item.setText(1, Const.NVL(name, ""));
+      item.setText(1, Const.NVL(displayName(info), ""));
       item.setText(2, Const.NVL(info.getVersion(), ""));
       item.setText(3, Const.NVL(info.getArtifactId(), ""));
       item.setText(4, Const.NVL(info.getCategory(), ""));
@@ -604,12 +629,28 @@ public class MarketplaceDialog extends Dialog {
     return true;
   }
 
-  private OptionalPluginInfo selected() {
+  /** The plugins highlighted in the table, in table order. Empty when nothing 
is selected. */
+  private List<OptionalPluginInfo> selected() {
     TableItem[] items = wTable.table.getSelection();
-    if (items == null || items.length == 0) {
-      return null;
+    if (items == null) {
+      return List.of();
     }
-    return (OptionalPluginInfo) items[0].getData();
+    List<OptionalPluginInfo> selection = new ArrayList<>();
+    for (TableItem item : items) {
+      if (item.getData() instanceof OptionalPluginInfo info) {
+        selection.add(info);
+      }
+    }
+    return selection;
+  }
+
+  /** Plugin names as the table shows them, one per line, for confirmations 
and summaries. */
+  private static String nameList(List<String> names) {
+    return names.stream().map(name -> "- " + 
name).collect(Collectors.joining("\n"));
+  }
+
+  private static String displayName(OptionalPluginInfo info) {
+    return Const.NVL(info.getName(), info.getArtifactId());
   }
 
   /**
@@ -632,77 +673,167 @@ public class MarketplaceDialog extends Dialog {
     return source;
   }
 
+  /** One plugin ready to install: where to get it, and what to call it while 
it downloads. */
+  private record InstallRequest(
+      MavenCoordinates coordinates, String preferredRepo, String displayName) 
{}
+
+  /** What a batch install did: what went in, what didn't, and whether the 
user stopped it. */
+  private record InstallOutcome(
+      List<String> installed, Map<String, Exception> failures, boolean 
cancelled) {}
+
+  /** Coordinates, repository and display name for one selected row. */
+  private InstallRequest toInstallRequest(OptionalPluginInfo info) {
+    String groupId =
+        StringUtils.isNotBlank(info.getGroupId()) ? info.getGroupId() : 
config.getGroupId();
+    String version =
+        StringUtils.isNotBlank(info.getVersion())
+            ? info.getVersion()
+            : MarketplaceCommand.resolveDefaultVersion(config);
+    String preferredRepo =
+        StringUtils.isNotBlank(info.getSource()) && 
!"apache".equalsIgnoreCase(info.getSource())
+            ? info.getSource()
+            : null;
+    return new InstallRequest(
+        new MavenCoordinates(groupId, info.getArtifactId(), version),
+        preferredRepo,
+        // Same name the table shows, not the Maven coordinate.
+        displayName(info));
+  }
+
   private void installSelected() {
-    OptionalPluginInfo info = selected();
-    if (info == null) {
+    List<OptionalPluginInfo> selection = selected();
+    if (selection.isEmpty()) {
       return;
     }
+    if (repositoriesPanel != null) {
+      config = repositoriesPanel.getConfig();
+    }
+    List<InstallRequest> requests = 
selection.stream().map(this::toInstallRequest).toList();
+    InstallOutcome outcome;
     try {
-      if (repositoriesPanel != null) {
-        config = repositoriesPanel.getConfig();
-      }
-      String groupId =
-          StringUtils.isNotBlank(info.getGroupId()) ? info.getGroupId() : 
config.getGroupId();
-      String version =
-          StringUtils.isNotBlank(info.getVersion())
-              ? info.getVersion()
-              : MarketplaceCommand.resolveDefaultVersion(config);
-      MavenCoordinates coords = new MavenCoordinates(groupId, 
info.getArtifactId(), version);
-      String preferredRepo =
-          StringUtils.isNotBlank(info.getSource()) && 
!"apache".equalsIgnoreCase(info.getSource())
-              ? info.getSource()
-              : null;
-      // Same name the table shows, not the Maven coordinate.
-      String displayName = Const.NVL(info.getName(), info.getArtifactId());
-      wStatus.setText(
-          BaseMessages.getString(PKG, "MarketplaceDialog.Status.Installing", 
coords.gav()));
+      wStatus.setText(installingStatus(requests));
       shell.update();
-      if (!runInstall(coords, preferredRepo, displayName)) {
-        // Cancelled by the user — no success popup, and the table still 
reflects reality.
-        refreshTable(RefreshReason.AFTER_CHANGE);
-        wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.Cancelled"));
-        return;
-      }
-      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
-      box.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Install.Done.Header"));
-      box.setMessage(
-          BaseMessages.getString(PKG, 
"MarketplaceDialog.Install.Done.Message", coords.gav()));
-      box.open();
-      refreshTable(RefreshReason.AFTER_CHANGE);
-      wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.RestartHint"));
+      outcome = runInstall(requests);
     } catch (InterruptedException e) {
       Thread.currentThread().interrupt();
-      showInstallError(info, e);
+      showInstallError(requests, e);
+      return;
     } catch (Exception e) {
-      showInstallError(info, e);
+      showInstallError(requests, e);
+      return;
+    }
+
+    // The table reflects what is on disk either way: a batch can be part 
installed.
+    refreshTable(RefreshReason.AFTER_CHANGE);
+    if (!outcome.failures().isEmpty()) {
+      showInstallFailures(outcome);
+      wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.RestartHint"));
+      return;
+    }
+    if (outcome.cancelled()) {
+      // Cancelled by the user — no success popup.
+      wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.Cancelled"));
+      return;
     }
+    boolean single = outcome.installed().size() == 1;
+    MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
+    box.setText(
+        BaseMessages.getString(
+            PKG,
+            single
+                ? "MarketplaceDialog.Install.Done.Header"
+                : "MarketplaceDialog.Install.Done.HeaderMultiple"));
+    box.setMessage(
+        single
+            ? BaseMessages.getString(
+                PKG, "MarketplaceDialog.Install.Done.Message", 
outcome.installed().get(0))
+            : BaseMessages.getString(
+                PKG,
+                "MarketplaceDialog.Install.Done.MessageMultiple",
+                Integer.toString(outcome.installed().size()),
+                nameList(outcome.installed())));
+    box.open();
+    wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.RestartHint"));
+  }
+
+  private String installingStatus(List<InstallRequest> requests) {
+    return requests.size() == 1
+        ? BaseMessages.getString(
+            PKG, "MarketplaceDialog.Status.Installing", 
requests.get(0).coordinates().gav())
+        : BaseMessages.getString(
+            PKG, "MarketplaceDialog.Status.InstallingCount", 
Integer.toString(requests.size()));
   }
 
-  private void showInstallError(OptionalPluginInfo info, Exception e) {
+  /** The batch never started, or the dialog itself broke down. */
+  private void showInstallError(List<InstallRequest> requests, Exception e) {
     // ProgressMonitorDialog wraps the real failure; unwrap so the dialog 
shows the cause.
     Throwable cause =
         e instanceof InvocationTargetException ite && ite.getCause() != null ? 
ite.getCause() : e;
     new ErrorDialog(
         shell,
         BaseMessages.getString(PKG, "MarketplaceDialog.Error.Header"),
-        BaseMessages.getString(PKG, "MarketplaceDialog.Install.Error", 
info.getArtifactId()),
+        BaseMessages.getString(
+            PKG,
+            "MarketplaceDialog.Install.Error",
+            
requests.stream().map(InstallRequest::displayName).collect(Collectors.joining(",
 "))),
         cause);
   }
 
+  /**
+   * Report the plugins the batch could not install. One dialog for the lot: a 
failing repository
+   * usually takes every plugin down with it, and a popup per plugin would 
have to be clicked away
+   * as many times.
+   */
+  private void showInstallFailures(InstallOutcome outcome) {
+    StringBuilder detail = new StringBuilder();
+    for (Map.Entry<String, Exception> failure : outcome.failures().entrySet()) 
{
+      log.logError("Failed to install " + failure.getKey(), 
failure.getValue());
+      detail
+          .append("- ")
+          .append(failure.getKey())
+          .append(": ")
+          .append(Const.NVL(failure.getValue().getMessage(), 
failure.getValue().toString()))
+          .append(Const.CR);
+    }
+    String header =
+        outcome.failures().size() == 1
+            ? BaseMessages.getString(
+                PKG,
+                "MarketplaceDialog.Install.Error",
+                outcome.failures().keySet().iterator().next())
+            : BaseMessages.getString(
+                PKG,
+                "MarketplaceDialog.Install.Error.Multiple",
+                Integer.toString(outcome.failures().size()),
+                Integer.toString(outcome.failures().size() + 
outcome.installed().size()));
+    // The first failure carries the stack trace; the rest are summarised in 
the message.
+    Exception first = outcome.failures().values().iterator().next();
+    new ErrorDialog(
+        shell,
+        BaseMessages.getString(PKG, "MarketplaceDialog.Error.Header"),
+        header + Const.CR + Const.CR + detail,
+        first);
+  }
+
   /**
    * Download and install on a worker thread behind a cancellable progress 
dialog, so the UI keeps
-   * repainting and the user can see the zip arrive.
+   * repainting and the user can see the zips arrive.
    *
    * <p>{@link ProgressMonitorDialog#run(boolean, IRunnableWithProgress)} 
pumps the event loop until
    * the work finishes and only then returns, so everything after the call is 
back on the UI thread
    * and needs no marshalling.
    *
-   * @return true when the plugin was installed, false when the user cancelled
-   * @throws InvocationTargetException wrapping whatever the install failed 
with
+   * <p>A failing plugin does not abort the batch — one unavailable artifact 
shouldn't cost the user
+   * the other nine installs — so failures are collected and reported together 
at the end. Nothing
+   * is thrown out of the operation, which also sidesteps a trap: {@code 
monitor.done()} disposes
+   * the progress shell and ends the pump loop, so anything thrown after it is 
never observed and a
+   * failed install would be reported as a success.
    */
-  private boolean runInstall(MavenCoordinates coords, String preferredRepo, 
String displayName)
+  private InstallOutcome runInstall(List<InstallRequest> requests)
       throws InvocationTargetException, InterruptedException {
     PluginInstaller installer = new PluginInstaller(log, hopHome, config);
+    List<String> installed = new ArrayList<>();
+    Map<String, Exception> failures = new LinkedHashMap<>();
     AtomicBoolean cancelled = new AtomicBoolean(false);
     ProgressMonitorDialog dialog = new ProgressMonitorDialog(shell);
 
@@ -710,63 +841,134 @@ public class MarketplaceDialog extends Dialog {
         monitor -> {
           ProgressMonitorInstallListener listener = new 
ProgressMonitorInstallListener(monitor);
           listener.begin(
-              BaseMessages.getString(PKG, 
"MarketplaceDialog.Progress.Installing", displayName),
-              displayName);
-          try {
-            installer.install(coords, true, null, preferredRepo, listener);
-          } catch (Exception e) {
-            if (!monitor.isCanceled()) {
-              // Throw WITHOUT calling monitor.done(). done() disposes the 
progress shell, which
-              // ends ProgressMonitorDialog's pump loop before it has a chance 
to observe this
-              // exception — a failed install would then be reported as a 
success. Leaving the shell
-              // up lets the pump see the exception and rethrow it. Catching 
Exception (not just
-              // HopException) is what keeps a RuntimeException from killing 
the worker thread
-              // silently and hanging the dialog forever.
-              throw new InvocationTargetException(e, e.getMessage());
+              installTaskName(requests),
+              requests.size() == 1 ? requests.get(0).displayName() : null);
+          for (int i = 0; i < requests.size(); i++) {
+            if (monitor.isCanceled()) {
+              cancelled.set(true);
+              break;
+            }
+            InstallRequest request = requests.get(i);
+            // Drives the "3 of 12" line and scales the bar across the batch.
+            listener.item(request.displayName(), i, requests.size());
+            try {
+              installer.install(
+                  request.coordinates(), true, null, request.preferredRepo(), 
listener);
+              installed.add(request.coordinates().gav());
+            } catch (Exception e) {
+              // Catching Exception (not just HopException) is what keeps a 
RuntimeException from
+              // killing the worker thread silently and hanging the dialog 
forever.
+              if (monitor.isCanceled()) {
+                cancelled.set(true);
+                break;
+              }
+              failures.put(request.displayName(), e);
             }
-            cancelled.set(true);
           }
           listener.complete();
           monitor.done();
         };
 
     dialog.run(true, operation);
-    return !cancelled.get();
+    return new InstallOutcome(installed, failures, cancelled.get());
+  }
+
+  private String installTaskName(List<InstallRequest> requests) {
+    return requests.size() == 1
+        ? BaseMessages.getString(
+            PKG, "MarketplaceDialog.Progress.Installing", 
requests.get(0).displayName())
+        : BaseMessages.getString(
+            PKG, "MarketplaceDialog.Progress.InstallingCount", 
Integer.toString(requests.size()));
   }
 
   private void uninstallSelected() {
-    OptionalPluginInfo info = selected();
-    if (info == null) {
+    List<OptionalPluginInfo> selection = selected();
+    if (selection.isEmpty()) {
       return;
     }
+    List<OptionalPluginInfo> removable = new ArrayList<>();
+    List<String> withoutReceipt = new ArrayList<>();
     try {
-      InstallReceipt receipt = PluginInstaller.readReceipt(hopHome, 
info.getArtifactId());
-      if (receipt == null) {
-        MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_WARNING);
-        box.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Uninstall.NoReceipt.Header"));
-        box.setMessage(
-            BaseMessages.getString(
-                PKG, "MarketplaceDialog.Uninstall.NoReceipt.Message", 
info.getArtifactId()));
-        box.open();
-        return;
+      for (OptionalPluginInfo info : selection) {
+        if (PluginInstaller.readReceipt(hopHome, info.getArtifactId()) == 
null) {
+          withoutReceipt.add(info.getArtifactId());
+        } else {
+          removable.add(info);
+        }
       }
-      MessageBox confirm = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
-      confirm.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Uninstall.Confirm.Header"));
-      confirm.setMessage(
-          BaseMessages.getString(
-              PKG, "MarketplaceDialog.Uninstall.Confirm.Message", 
info.getArtifactId()));
-      if (confirm.open() != SWT.YES) {
+    } catch (Exception e) {
+      
showUninstallError(selection.stream().map(OptionalPluginInfo::getArtifactId).toList(),
 e);
+      return;
+    }
+
+    // Plugins that ship with the client have no receipt; say so instead of 
silently skipping them.
+    if (!withoutReceipt.isEmpty()) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_WARNING);
+      box.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Uninstall.NoReceipt.Header"));
+      box.setMessage(
+          withoutReceipt.size() == 1
+              ? BaseMessages.getString(
+                  PKG, "MarketplaceDialog.Uninstall.NoReceipt.Message", 
withoutReceipt.get(0))
+              : BaseMessages.getString(
+                  PKG,
+                  "MarketplaceDialog.Uninstall.NoReceipt.MessageMultiple",
+                  nameList(withoutReceipt)));
+      box.open();
+      if (removable.isEmpty()) {
         return;
       }
-      new PluginUninstaller(log, hopHome).uninstall(info.getArtifactId());
-      refreshTable(RefreshReason.AFTER_CHANGE);
-      wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.RestartHint"));
-    } catch (Exception e) {
-      new ErrorDialog(
-          shell,
-          BaseMessages.getString(PKG, "MarketplaceDialog.Error.Header"),
-          BaseMessages.getString(PKG, "MarketplaceDialog.Uninstall.Error", 
info.getArtifactId()),
-          e);
     }
+
+    List<String> artifactIds = 
removable.stream().map(OptionalPluginInfo::getArtifactId).toList();
+    MessageBox confirm = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+    confirm.setText(
+        BaseMessages.getString(
+            PKG,
+            artifactIds.size() == 1
+                ? "MarketplaceDialog.Uninstall.Confirm.Header"
+                : "MarketplaceDialog.Uninstall.Confirm.HeaderMultiple"));
+    confirm.setMessage(
+        artifactIds.size() == 1
+            ? BaseMessages.getString(
+                PKG, "MarketplaceDialog.Uninstall.Confirm.Message", 
artifactIds.get(0))
+            : BaseMessages.getString(
+                PKG,
+                "MarketplaceDialog.Uninstall.Confirm.MessageMultiple",
+                Integer.toString(artifactIds.size()),
+                nameList(artifactIds)));
+    if (confirm.open() != SWT.YES) {
+      return;
+    }
+
+    PluginUninstaller uninstaller = new PluginUninstaller(log, hopHome);
+    List<String> failed = new ArrayList<>();
+    Exception firstFailure = null;
+    for (String artifactId : artifactIds) {
+      try {
+        uninstaller.uninstall(artifactId);
+      } catch (Exception e) {
+        // Same reasoning as the install batch: report everything that went 
wrong, once.
+        log.logError("Failed to uninstall " + artifactId, e);
+        failed.add(artifactId);
+        if (firstFailure == null) {
+          firstFailure = e;
+        }
+      }
+    }
+    refreshTable(RefreshReason.AFTER_CHANGE);
+    if (firstFailure != null) {
+      showUninstallError(failed, firstFailure);
+      return;
+    }
+    wStatus.setText(BaseMessages.getString(PKG, 
"MarketplaceDialog.Status.RestartHint"));
+  }
+
+  private void showUninstallError(List<String> artifactIds, Exception e) {
+    new ErrorDialog(
+        shell,
+        BaseMessages.getString(PKG, "MarketplaceDialog.Error.Header"),
+        BaseMessages.getString(
+            PKG, "MarketplaceDialog.Uninstall.Error", String.join(", ", 
artifactIds)),
+        e);
   }
 }
diff --git 
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/SearchMarketplaceExtensionPoint.java
 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/SearchMarketplaceExtensionPoint.java
new file mode 100644
index 0000000000..d1c77e841e
--- /dev/null
+++ 
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/SearchMarketplaceExtensionPoint.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.marketplace.xp;
+
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.marketplace.gui.MarketplaceDialog;
+import org.apache.hop.marketplace.gui.MarketplaceGuiPlugin;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.hopgui.HopGui;
+
+/**
+ * Opens the marketplace with the search box pre-filled, so the GUI can offer 
a way out of a missing
+ * plugin without depending on the marketplace plugin being installed.
+ *
+ * <p>The payload is the plugin id to search for; blank or null lists 
everything.
+ */
+@ExtensionPoint(
+    id = "SearchMarketplaceExtensionPoint",
+    description = "Open the marketplace, searching for a plugin id",
+    extensionPointId = "HopGuiSearchMarketplace")
+public class SearchMarketplaceExtensionPoint implements 
IExtensionPoint<Object> {
+
+  private static final Class<?> PKG = MarketplaceGuiPlugin.class;
+
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, Object 
object) {
+    HopGui hopGui = HopGui.getInstance();
+    String search = object instanceof String s ? s : null;
+    try {
+      new MarketplaceDialog(hopGui.getShell(), search).open();
+    } catch (Exception e) {
+      new ErrorDialog(
+          hopGui.getShell(),
+          BaseMessages.getString(PKG, "MarketplaceDialog.Error.Header"),
+          BaseMessages.getString(PKG, "MarketplaceDialog.Error.Open"),
+          e);
+    }
+  }
+}
diff --git 
a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
 
b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
index 3bd79da568..960f21ea8d 100644
--- 
a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
+++ 
b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/gui/messages/messages_en_US.properties
@@ -79,11 +79,13 @@ MarketplaceDialog.Status.Present=Present on disk
 MarketplaceDialog.Status.NotInstalled=Not installed
 MarketplaceDialog.Status.RestartHint=Restart Hop after install, apply, or 
uninstall so plugins are reloaded. Install tries the primary repository, then 
other enabled repositories.
 MarketplaceDialog.Status.Installing=Installing {0}...
+MarketplaceDialog.Status.InstallingCount=Installing {0} plugins...
 MarketplaceDialog.Status.Validating=Validating environment file...
 MarketplaceDialog.Status.Applying=Applying environment file...
 MarketplaceDialog.Status.Drift=Environment drift detected — use Apply to fix
 MarketplaceDialog.Status.Cancelled=Cancelled.
 MarketplaceDialog.Progress.Installing=Installing {0}
+MarketplaceDialog.Progress.InstallingCount=Installing {0} plugins
 MarketplaceDialog.Progress.Applying=Applying environment file
 MarketplaceDialog.Progress.Searching=Searching repositories...
 MarketplaceDialog.Progress.Item={0} ({1} of {2})
@@ -105,14 +107,21 @@ MarketplaceDialog.Progress.Eta.Hour={0} hr left
 MarketplaceDialog.Progress.Eta.Hours={0} hrs left
 MarketplaceDialog.Refresh.Error=Failed to list plugins from the configured 
repositories
 MarketplaceDialog.Error.Header=Marketplace error
+MarketplaceDialog.Error.Open=Unable to open the marketplace dialog
 MarketplaceDialog.Error.ResolveHome=Could not determine the Hop installation 
directory. Start Hop from the install root using the hop launcher.
 MarketplaceDialog.Install.Done.Header=Plugin installed
+MarketplaceDialog.Install.Done.HeaderMultiple=Plugins installed
 MarketplaceDialog.Install.Done.Message=Installed {0}.\n\nPlease restart Hop to 
load the plugin.
+MarketplaceDialog.Install.Done.MessageMultiple=Installed {0} 
plugins:\n\n{1}\n\nPlease restart Hop to load them.
 MarketplaceDialog.Install.Error=Failed to install {0}
+MarketplaceDialog.Install.Error.Multiple=Failed to install {0} of {1} plugins
 MarketplaceDialog.Uninstall.Confirm.Header=Uninstall plugin
+MarketplaceDialog.Uninstall.Confirm.HeaderMultiple=Uninstall plugins
 MarketplaceDialog.Uninstall.Confirm.Message=Remove marketplace-installed 
plugin {0}?
+MarketplaceDialog.Uninstall.Confirm.MessageMultiple=Remove {0} 
marketplace-installed plugins?\n\n{1}
 MarketplaceDialog.Uninstall.NoReceipt.Header=Cannot uninstall
 MarketplaceDialog.Uninstall.NoReceipt.Message=No marketplace install receipt 
for {0}. Only plugins installed via the marketplace can be uninstalled here.
+MarketplaceDialog.Uninstall.NoReceipt.MessageMultiple=No marketplace install 
receipt for:\n\n{0}\n\nOnly plugins installed via the marketplace can be 
uninstalled here.
 MarketplaceDialog.Uninstall.Error=Failed to uninstall {0}
 MarketplaceDialog.Validate.Ok.Header=Environment OK
 MarketplaceDialog.Validate.Ok.Message=Local install matches:\n{0}
diff --git 
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
 
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
new file mode 100644
index 0000000000..ef106bafd6
--- /dev/null
+++ 
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.marketplace.command;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import picocli.CommandLine;
+
+/**
+ * {@code hop marketplace install} takes a list of coordinates. Arity is easy 
to break by accident
+ * (an {@code index = "0"} positional silently swallows only the first word), 
so the contract is
+ * pinned here rather than found out on a batch install.
+ */
+class InstallCommandParsingTest {
+
+  private static List<String> parseCoordinates(String... args) {
+    CommandLine commandLine = new CommandLine(new 
MarketplaceCommand.InstallCommand());
+    return commandLine.parseArgs(args).matchedPositional(0).getValue();
+  }
+
+  @Test
+  void severalCoordinatesAreAllCaptured() {
+    assertEquals(
+        List.of("datavault", "hop-tech-parquet", 
"hop-datavault:0.4.0-SNAPSHOT"),
+        parseCoordinates("datavault", "hop-tech-parquet", 
"hop-datavault:0.4.0-SNAPSHOT"));
+  }
+
+  @Test
+  void aSingleCoordinateStillWorks() {
+    assertEquals(List.of("datavault"), parseCoordinates("datavault"));
+  }
+
+  @Test
+  void optionsDoNotEndUpInTheCoordinateList() {
+    CommandLine commandLine = new CommandLine(new 
MarketplaceCommand.InstallCommand());
+    CommandLine.ParseResult parsed =
+        commandLine.parseArgs("--repo", "local-nexus", "datavault", 
"hop-tech-parquet");
+    assertEquals(List.of("datavault", "hop-tech-parquet"), 
parsed.matchedPositional(0).getValue());
+    assertEquals("local-nexus", parsed.matchedOptionValue("--repo", null));
+  }
+
+  @Test
+  void atLeastOneCoordinateIsRequired() {
+    assertThrows(CommandLine.MissingParameterException.class, 
this::parseNothing);
+  }
+
+  private void parseNothing() {
+    new CommandLine(new MarketplaceCommand.InstallCommand()).parseArgs();
+  }
+}
diff --git a/plugins/tech/azure/pom.xml b/plugins/tech/azure/pom.xml
index 15c1e1cd81..9bb2dd2717 100755
--- a/plugins/tech/azure/pom.xml
+++ b/plugins/tech/azure/pom.xml
@@ -323,6 +323,17 @@
             <artifactId>netty-codec-http2</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-codec-http3</artifactId>
+            <scope>provided</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>io.netty</groupId>
+                    <artifactId>netty-codec-native-quic</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
         <dependency>
             <groupId>io.netty</groupId>
             <artifactId>netty-common</artifactId>
diff --git 
a/ui/src/main/java/org/apache/hop/ui/pipeline/transforms/missing/MissingPipelineDialog.java
 
b/ui/src/main/java/org/apache/hop/ui/pipeline/transforms/missing/MissingPipelineDialog.java
index 86a285619e..d8b54c7ffa 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/pipeline/transforms/missing/MissingPipelineDialog.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/pipeline/transforms/missing/MissingPipelineDialog.java
@@ -17,6 +17,10 @@
 package org.apache.hop.ui.pipeline.transforms.missing;
 
 import java.util.List;
+import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.ExtensionPointMap;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.pipeline.PipelineMeta;
@@ -25,6 +29,7 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transforms.missing.Missing;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
@@ -92,6 +97,34 @@ public class MissingPipelineDialog extends 
BaseTransformDialog {
     return entries.toString();
   }
 
+  /**
+   * The plugin id to hand to the marketplace search. A single missing plugin 
gives an obvious
+   * search term; a file missing several different plugins gives none, so the 
marketplace opens
+   * unfiltered and the user picks from the list in the message.
+   */
+  private String marketplaceSearchFilter() {
+    if (mode != MISSING_PIPELINE_TRANSFORMS) {
+      return ((Missing) baseTransformMeta).getMissingPluginId();
+    }
+    List<String> pluginIds =
+        
missingPipeline.stream().map(Missing::getMissingPluginId).distinct().toList();
+    return pluginIds.size() == 1 ? pluginIds.get(0) : null;
+  }
+
+  /** Hand the plugin id to whoever implements the marketplace (the 
marketplace plugin). */
+  private void openMarketplace(String filter) {
+    try {
+      ExtensionPointHandler.callExtensionPoint(
+          LogChannel.UI, variables, 
HopExtensionPoint.HopGuiSearchMarketplace.id, filter);
+    } catch (Exception e) {
+      new ErrorDialog(
+          shellParent,
+          BaseMessages.getString(PKG, "MissingPipelineDialog.MissingPlugins"),
+          BaseMessages.getString(PKG, 
"MissingPipelineDialog.SearchMarketplace.Error"),
+          e);
+    }
+  }
+
   private String buildMessage() {
     if (mode == MISSING_PIPELINE_TRANSFORMS) {
       return BaseMessages.getString(
@@ -177,6 +210,7 @@ public class MissingPipelineDialog extends 
BaseTransformDialog {
           }
         });
 
+    Button previousButton = closeButton;
     if (showOpenFile) {
       Button openButton = new Button(dialogShell, SWT.PUSH);
       PropsUi.setLook(openButton);
@@ -192,6 +226,33 @@ public class MissingPipelineDialog extends 
BaseTransformDialog {
               confirm.run();
             }
           });
+      previousButton = openButton;
+    }
+
+    // Only when the marketplace plugin is installed to answer the call.
+    if (ExtensionPointMap.getInstance()
+        .isRegistered(HopExtensionPoint.HopGuiSearchMarketplace.id)) {
+      Button marketplaceButton = new Button(dialogShell, SWT.PUSH);
+      PropsUi.setLook(marketplaceButton);
+      FormData fdMarketplace = new FormData();
+      fdMarketplace.right = new FormAttachment(previousButton, -5);
+      fdMarketplace.bottom = new FormAttachment(closeButton, 0, SWT.BOTTOM);
+      marketplaceButton.setLayoutData(fdMarketplace);
+      marketplaceButton.setText(
+          BaseMessages.getString(PKG, 
"MissingPipelineDialog.SearchMarketplace"));
+      marketplaceButton.setToolTipText(
+          BaseMessages.getString(PKG, 
"MissingPipelineDialog.SearchMarketplace.Tooltip"));
+      marketplaceButton.addSelectionListener(
+          new SelectionAdapter() {
+            @Override
+            public void widgetSelected(SelectionEvent e) {
+              String filter = marketplaceSearchFilter();
+              cancel.run();
+              // The marketplace is modal and runs its own event loop, so let 
this dialog finish
+              // closing first instead of nesting one modal shell inside 
another.
+              display.asyncExec(() -> openMarketplace(filter));
+            }
+          });
     }
 
     BaseDialog.defaultShellHandling(dialogShell, v -> confirm.run(), v -> 
cancel.run());
diff --git 
a/ui/src/main/java/org/apache/hop/ui/workflow/actions/missing/MissingActionDialog.java
 
b/ui/src/main/java/org/apache/hop/ui/workflow/actions/missing/MissingActionDialog.java
index e43290b271..af8028b5fd 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/workflow/actions/missing/MissingActionDialog.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/workflow/actions/missing/MissingActionDialog.java
@@ -18,10 +18,15 @@
 package org.apache.hop.ui.workflow.actions.missing;
 
 import java.util.List;
+import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.ExtensionPointMap;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.workflow.action.ActionDialog;
@@ -81,6 +86,34 @@ public class MissingActionDialog extends ActionDialog {
     return entries.toString();
   }
 
+  /**
+   * The plugin id to hand to the marketplace search. A single missing plugin 
gives an obvious
+   * search term; a file missing several different plugins gives none, so the 
marketplace opens
+   * unfiltered and the user picks from the list in the message.
+   */
+  private String marketplaceSearchFilter() {
+    if (mode != MISSING_ACTIONS) {
+      return ((MissingAction) action).getMissingPluginId();
+    }
+    List<String> pluginIds =
+        
missingActions.stream().map(MissingAction::getMissingPluginId).distinct().toList();
+    return pluginIds.size() == 1 ? pluginIds.get(0) : null;
+  }
+
+  /** Hand the plugin id to whoever implements the marketplace (the 
marketplace plugin). */
+  private void openMarketplace(String filter) {
+    try {
+      ExtensionPointHandler.callExtensionPoint(
+          LogChannel.UI, variables, 
HopExtensionPoint.HopGuiSearchMarketplace.id, filter);
+    } catch (Exception e) {
+      new ErrorDialog(
+          getParent(),
+          BaseMessages.getString(PKG, "MissingActionDialog.MissingPlugins"),
+          BaseMessages.getString(PKG, 
"MissingActionDialog.SearchMarketplace.Error"),
+          e);
+    }
+  }
+
   private String buildMessage() {
     if (mode == MISSING_ACTIONS) {
       return BaseMessages.getString(
@@ -163,6 +196,7 @@ public class MissingActionDialog extends ActionDialog {
           }
         });
 
+    Button previousButton = closeButton;
     if (showOpenFile) {
       Button openButton = new Button(dialogShell, SWT.PUSH);
       PropsUi.setLook(openButton);
@@ -178,6 +212,33 @@ public class MissingActionDialog extends ActionDialog {
               confirm.run();
             }
           });
+      previousButton = openButton;
+    }
+
+    // Only when the marketplace plugin is installed to answer the call.
+    if (ExtensionPointMap.getInstance()
+        .isRegistered(HopExtensionPoint.HopGuiSearchMarketplace.id)) {
+      Button marketplaceButton = new Button(dialogShell, SWT.PUSH);
+      PropsUi.setLook(marketplaceButton);
+      FormData fdMarketplace = new FormData();
+      fdMarketplace.right = new FormAttachment(previousButton, -5);
+      fdMarketplace.bottom = new FormAttachment(closeButton, 0, SWT.BOTTOM);
+      marketplaceButton.setLayoutData(fdMarketplace);
+      marketplaceButton.setText(
+          BaseMessages.getString(PKG, 
"MissingActionDialog.SearchMarketplace"));
+      marketplaceButton.setToolTipText(
+          BaseMessages.getString(PKG, 
"MissingActionDialog.SearchMarketplace.Tooltip"));
+      marketplaceButton.addSelectionListener(
+          new SelectionAdapter() {
+            @Override
+            public void widgetSelected(SelectionEvent e) {
+              String filter = marketplaceSearchFilter();
+              cancel.run();
+              // The marketplace is modal and runs its own event loop, so let 
this dialog finish
+              // closing first instead of nesting one modal shell inside 
another.
+              display.asyncExec(() -> openMarketplace(filter));
+            }
+          });
     }
 
     BaseDialog.defaultShellHandling(dialogShell, v -> confirm.run(), v -> 
cancel.run());
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/pipeline/transforms/missing/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/pipeline/transforms/missing/messages/messages_en_US.properties
index 55afd8cfd6..7ea2c73655 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/pipeline/transforms/missing/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/pipeline/transforms/missing/messages/messages_en_US.properties
@@ -21,3 +21,6 @@ MissingPipelineDialog.MissingPipelineTransformId=The {0} 
plugin is missing.\nRem
 MissingPipelineDialog.MissingPipelineTransforms=This file has missing plugins 
and will not succesfully run.\nYou can open the file and replace the missing 
transform(s):\n\n{0}Or install the missing plugin(s).
 MissingPipelineDialog.MissingPlugins=Missing Plugins
 MissingPipelineDialog.OpenFile=Open the File
+MissingPipelineDialog.SearchMarketplace=Search the Marketplace
+MissingPipelineDialog.SearchMarketplace.Error=Unable to open the marketplace
+MissingPipelineDialog.SearchMarketplace.Tooltip=Look for the missing plugin in 
the marketplace
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/workflow/actions/missing/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/workflow/actions/missing/messages/messages_en_US.properties
index 2e5a3afe0e..02112c846d 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/workflow/actions/missing/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/workflow/actions/missing/messages/messages_en_US.properties
@@ -21,3 +21,6 @@ MissingActionDialog.MissingActionId=The {0} plugin is 
missing.\nRemove this acti
 MissingActionDialog.MissingActions=This file has missing plugins and will not 
succesfully run.\nYou can open the file and replace the missing 
actions:\n\n{0}Or install the missing plugin(s).
 MissingActionDialog.MissingPlugins=Missing Plugins
 MissingActionDialog.OpenFile=Open the File
+MissingActionDialog.SearchMarketplace=Search the Marketplace
+MissingActionDialog.SearchMarketplace.Error=Unable to open the marketplace
+MissingActionDialog.SearchMarketplace.Tooltip=Look for the missing plugin in 
the marketplace

Reply via email to