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 635603cb88 Allow to open multiple pipelines or workflows, fixes  #3132 
(#7665)
635603cb88 is described below

commit 635603cb886d3708bc4911836da37bf38697902a
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Jul 28 15:25:05 2026 +0200

    Allow to open multiple pipelines or workflows, fixes  #3132 (#7665)
---
 .../modules/ROOT/pages/hop-gui/file-dialog.adoc    |   2 +
 .../pages/hop-gui/perspective-file-explorer.adoc   |   8 +-
 .../core/vfs/HopVfsFileDialogMultiSelectTest.java  | 210 +++++++++++++++++++++
 .../org/apache/hop/ui/core/dialog/BaseDialog.java  | 120 ++++++++++--
 .../org/apache/hop/ui/core/dialog/IFileDialog.java |  22 +++
 .../hop/ui/core/dialog/NativeFileDialog.java       |  26 +++
 .../apache/hop/ui/core/vfs/HopVfsFileDialog.java   | 139 +++++++++++++-
 .../ui/hopgui/delegates/HopGuiFileDelegate.java    |  42 ++++-
 .../perspective/explorer/ExplorerPerspective.java  | 119 +++++++++---
 .../ui/core/vfs/messages/messages_en_US.properties |   1 +
 10 files changed, 639 insertions(+), 50 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
index 0456b1635c..bb3d1d2ad5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
@@ -61,6 +61,8 @@ Tools -> Options contains an option to specify whether you 
want or don't want gl
 .. image:icons/bookmark-add.svg[width="25px"]Add the selected file or folder 
as a bookmark
 .. image:icons/delete.svg[width="25px"]Remove the selected bookmark
 . **File/folder browser**: Browse your current file system, create folders and 
toggle hidden files and folders.
+When the dialog is used to open pipelines or workflows (`File -> Open`), you 
can select more than one file at once with `Ctrl` (`Cmd` on macOS) or `Shift`.
+All selected files are opened in their own tab.
 .. image:icons/folder-add.svg[width="25px"]Create folder
 .. image:icons/zipfile.svg[width="25px"]Drill into a selected archive (zip, 
jar, tar, tgz, tbz2) using VFS. Double-clicking an archive does the same; use 
*OK* if you need to select the archive file itself. Directory browsers also 
list these archives so you can open a folder inside a zip (for example a 
project home).
 .. image:icons/preview.svg[width="25px"]Show or hide hidden files and 
directories
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-file-explorer.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-file-explorer.adoc
index adb349bd3b..9887829321 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-file-explorer.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-file-explorer.adoc
@@ -39,12 +39,12 @@ The file operations are available through a number of 
toolbar options:
 
 image::hop-gui/perspective-explorer-toolbar-items.png[File explorer toolbar 
items]
 
-* Open selected file: Use the right arrow or double-click to open a selected 
file. You can also open a file simply by double-clicking it. If this is a 
workflow or pipeline it will be opened in the 
xref:hop-gui/perspective-data-orchestration.adoc[Data Orchestation] 
perspective. In all other cases the file will be opened in a new tab.
-* Create folder: Add a new folder.
+* Open selected file: Use the right arrow or double-click to open a selected 
file. You can also open a file simply by double-clicking it. If this is a 
workflow or pipeline it will be opened in the 
xref:hop-gui/perspective-data-orchestration.adoc[Data Orchestation] 
perspective. In all other cases the file will be opened in a new tab. Select 
multiple files with `Ctrl` (`Cmd` on macOS) or `Shift` to open them all at once.
+* Create folder: Add a new folder. This works on a single selected folder.
 * Expand all folders: Display all nested folders in the directory tree.
 * Collapse all folders: Hide all nested folders in the directory tree.
-* Delete: Remove a selected file or folder.
-* Rename: Rename a file or folder.
+* Delete: Remove a selected file or folder. Only one file or folder at a time.
+* Rename: Rename a file or folder. Only one file or folder at a time.
 * Refresh: Refresh the file list.
 * Show or hide files: Show or hide files or directories.
 
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogMultiSelectTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogMultiSelectTest.java
new file mode 100644
index 0000000000..14032db6b0
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogMultiSelectTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.ui.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotTreeItem;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Drives the real Open file dialog (the Hop VFS browser) to verify that more 
than one file can be
+ * picked in one go, which is what File/Open in the Hop GUI relies on.
+ */
+@Tag("uitest")
+class HopVfsFileDialogMultiSelectTest extends SwtBotTestBase {
+
+  private static final String[] FILTER_EXTENSIONS = {"*.hpl"};
+  private static final String[] FILTER_NAMES = {"Pipelines"};
+
+  @TempDir private Path folder;
+
+  @Test
+  void multipleSelectedFilesAreAllReturned() throws Exception {
+    createPipelineFile("first.hpl");
+    createPipelineFile("second.hpl");
+    createPipelineFile("third.hpl");
+
+    List<String> listedFiles = new ArrayList<>();
+    String[] filenames =
+        openFilesInDialog(
+            bot -> {
+              selectFiles(bot, "first.hpl", "third.hpl");
+              listedFiles.addAll(listedFiles(bot));
+            });
+
+    // The files come back in the order the browser lists them, whatever the 
active sort order is.
+    //
+    List<String> expected =
+        listedFiles.stream().filter(name -> 
!"second.hpl".equals(name)).toList();
+    assertEquals(2, expected.size(), "The browser should list the three 
pipeline files");
+    assertEquals(2, filenames.length, "Both selected files should be 
returned");
+    assertTrue(
+        filenames[0].endsWith(expected.get(0)), "Unexpected first selected 
file: " + filenames[0]);
+    assertTrue(
+        filenames[1].endsWith(expected.get(1)), "Unexpected second selected 
file: " + filenames[1]);
+  }
+
+  @Test
+  void selectingOneFileStillReturnsThatFile() throws Exception {
+    createPipelineFile("only.hpl");
+    createPipelineFile("other.hpl");
+
+    String[] filenames = openFilesInDialog(bot -> selectFiles(bot, 
"other.hpl"));
+
+    assertEquals(1, filenames.length, "A single selection should return 
exactly one file");
+    assertTrue(filenames[0].endsWith("other.hpl"), "Unexpected selected file: 
" + filenames[0]);
+  }
+
+  @Test
+  void singleFileDialogKeepsReturningOneFilename() throws Exception {
+    createPipelineFile("single.hpl");
+
+    // The dialog every other caller in Hop uses: one file, one filename.
+    //
+    AtomicReference<String> selected = new AtomicReference<>();
+    withDialog(
+        parent -> selected.set(presentSingleFileDialog(parent)),
+        bot -> {
+          SWTBotShell shell = openFileShell(bot);
+          selectFiles(bot, "single.hpl");
+          shell.bot().button(buttonLabel("System.Button.Open")).click();
+        });
+
+    assertNotNull(selected.get(), "The single file dialog should return the 
selected file");
+    assertTrue(selected.get().endsWith("single.hpl"), "Unexpected file: " + 
selected.get());
+  }
+
+  @Test
+  void foldersInTheSelectionAreIgnored() throws Exception {
+    Files.createDirectory(folder.resolve("subfolder"));
+    createPipelineFile("kept.hpl");
+
+    String[] filenames = openFilesInDialog(bot -> selectFiles(bot, 
"subfolder", "kept.hpl"));
+
+    assertEquals(1, filenames.length, "Only the file should be returned, not 
the folder");
+    assertTrue(filenames[0].endsWith("kept.hpl"), "Unexpected selected file: " 
+ filenames[0]);
+  }
+
+  @Test
+  void cancellingTheDialogSelectsNothing() throws Exception {
+    createPipelineFile("untouched.hpl");
+
+    AtomicReference<String[]> selected = new AtomicReference<>();
+    withDialog(
+        parent -> selected.set(presentDialog(parent)),
+        bot -> {
+          SWTBotShell shell = openFileShell(bot);
+          selectFiles(bot, "untouched.hpl");
+          shell.bot().button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(0, selected.get().length, "A cancelled dialog should not 
select anything");
+  }
+
+  /**
+   * Opens the file dialog on the temporary folder, lets the caller pick files 
in it and returns
+   * what the dialog reports back.
+   */
+  private String[] openFilesInDialog(Consumer<SWTBot> selection) {
+    AtomicReference<String[]> selected = new AtomicReference<>();
+    withDialog(
+        parent -> selected.set(presentDialog(parent)),
+        bot -> {
+          SWTBotShell shell = openFileShell(bot);
+          selection.accept(bot);
+          shell.bot().button(buttonLabel("System.Button.Open")).click();
+        });
+    return selected.get();
+  }
+
+  private String[] presentDialog(Shell parent) {
+    try {
+      return BaseDialog.presentMultiFileDialog(
+          parent,
+          new Variables(),
+          HopVfs.getFileObject(folder.toString()),
+          FILTER_EXTENSIONS,
+          FILTER_NAMES,
+          false);
+    } catch (Exception e) {
+      throw new IllegalStateException("Error opening the file dialog", e);
+    }
+  }
+
+  private String presentSingleFileDialog(Shell parent) {
+    try {
+      return BaseDialog.presentFileDialog(
+          false,
+          parent,
+          null,
+          new Variables(),
+          HopVfs.getFileObject(folder.toString()),
+          FILTER_EXTENSIONS,
+          FILTER_NAMES,
+          false);
+    } catch (Exception e) {
+      throw new IllegalStateException("Error opening the file dialog", e);
+    }
+  }
+
+  private SWTBotShell openFileShell(SWTBot bot) {
+    SWTBotShell shell = bot.shell(BaseMessages.getString(BaseDialog.class, 
"BaseDialog.OpenFile"));
+    shell.activate();
+    return shell;
+  }
+
+  /** Selects the given files in the browser tree of the open file dialog. */
+  private void selectFiles(SWTBot bot, String... names) {
+    SWTBotTreeItem folderItem = folderItem(bot);
+    folderItem.expand();
+    folderItem.select(names);
+  }
+
+  /** The files as the browser lists them, in the order they are shown. */
+  private List<String> listedFiles(SWTBot bot) {
+    return folderItem(bot).getNodes();
+  }
+
+  private SWTBotTreeItem folderItem(SWTBot bot) {
+    return bot.tree().getTreeItem(folder.getFileName().toString());
+  }
+
+  private void createPipelineFile(String name) throws IOException {
+    Files.writeString(folder.resolve(name), "<pipeline/>");
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/BaseDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/BaseDialog.java
index 9a8e38eafb..53a767b823 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/BaseDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/BaseDialog.java
@@ -363,6 +363,82 @@ public abstract class BaseDialog extends Dialog {
       String[] filterExtensions,
       String[] filterNames,
       boolean folderAndFile) {
+    String[] filenames =
+        presentFileDialog(
+            save,
+            shell,
+            textVar,
+            variables,
+            fileObject,
+            filterExtensions,
+            filterNames,
+            folderAndFile,
+            false);
+    return filenames.length == 0 ? null : filenames[0];
+  }
+
+  /**
+   * Open a File browser dialog in which the user can select more than one 
file. Selecting a single
+   * file simply results in an array with one element.
+   *
+   * @param shell the Shell to attach the dialog to
+   * @param variables IVariables to resolve variables in the dialog
+   * @param filterExtensions String[] containing a list of extensions to 
filter on
+   * @param filterNames String[] names for the filterExtensions
+   * @param folderAndFile boolean to enable the dialog to open both files and 
folders
+   * @return the full paths of the selected files, empty when the dialog was 
cancelled
+   */
+  public static String[] presentMultiFileDialog(
+      Shell shell,
+      IVariables variables,
+      String[] filterExtensions,
+      String[] filterNames,
+      boolean folderAndFile) {
+    return presentMultiFileDialog(
+        shell, variables, null, filterExtensions, filterNames, folderAndFile);
+  }
+
+  /**
+   * Open a File browser dialog in which the user can select more than one 
file. Selecting a single
+   * file simply results in an array with one element.
+   *
+   * @param shell the Shell to attach the dialog to
+   * @param variables IVariables to resolve variables in the dialog
+   * @param fileObject the FileObject to navigate to
+   * @param filterExtensions String[] containing a list of extensions to 
filter on
+   * @param filterNames String[] names for the filterExtensions
+   * @param folderAndFile boolean to enable the dialog to open both files and 
folders
+   * @return the full paths of the selected files, empty when the dialog was 
cancelled
+   */
+  public static String[] presentMultiFileDialog(
+      Shell shell,
+      IVariables variables,
+      FileObject fileObject,
+      String[] filterExtensions,
+      String[] filterNames,
+      boolean folderAndFile) {
+    return presentFileDialog(
+        false,
+        shell,
+        null,
+        variables,
+        fileObject,
+        filterExtensions,
+        filterNames,
+        folderAndFile,
+        true);
+  }
+
+  private static String[] presentFileDialog(
+      boolean save,
+      Shell shell,
+      TextVar textVar,
+      IVariables variables,
+      FileObject fileObject,
+      String[] filterExtensions,
+      String[] filterNames,
+      boolean folderAndFile,
+      boolean multiSelection) {
 
     boolean useNativeFileDialog =
         
HopGui.getInstance().getVariables().getVariableBoolean(HOP_USE_NATIVE_FILE_DIALOG,
 false);
@@ -370,7 +446,13 @@ public abstract class BaseDialog extends Dialog {
     IFileDialog dialog;
 
     if (useNativeFileDialog) {
-      FileDialog fileDialog = new FileDialog(shell, save ? SWT.SAVE : 
SWT.OPEN);
+      // The native dialog only accepts multiple files when it is created with 
SWT.MULTI.
+      //
+      int style = save ? SWT.SAVE : SWT.OPEN;
+      if (multiSelection) {
+        style |= SWT.MULTI;
+      }
+      FileDialog fileDialog = new FileDialog(shell, style);
       dialog = new NativeFileDialog(fileDialog);
     } else {
       HopVfsFileDialog vfsDialog =
@@ -459,6 +541,7 @@ public abstract class BaseDialog extends Dialog {
       dialog.setFilterExtensions(filterExtensions);
       dialog.setFilterNames(filterNames);
     }
+    dialog.setMultiSelection(multiSelection);
 
     AtomicBoolean doIt = new AtomicBoolean(true);
     try {
@@ -505,32 +588,47 @@ public abstract class BaseDialog extends Dialog {
       dialog.setFileName(variables.resolve(textVar.getText()));
     }
 
-    String filename = null;
-    if (!doIt.get() || dialog.open() != null) {
-      filename = buildFilename(dialog.getFilterPath(), dialog.getFileName());
+    if (doIt.get() && dialog.open() == null) {
+      // The dialog was cancelled
+      //
+      return new String[0];
+    }
+
+    // More than one file selected? Otherwise we simply have the one file the 
dialog reports.
+    //
+    String[] filenames = dialog.getFileNames();
+    if (filenames.length == 0) {
+      filenames = new String[] {buildFilename(dialog.getFilterPath(), 
dialog.getFileName())};
+    }
+
+    // Give plugins the chance to rewrite every selected filename, e.g. to 
make it relative to
+    // the project home. This is done per file since the extension point 
handles one at a time.
+    //
+    for (int i = 0; i < filenames.length; i++) {
       try {
         HopGuiFileOpenedExtension openedExtension =
-            new HopGuiFileOpenedExtension(dialog, variables, filename);
+            new HopGuiFileOpenedExtension(dialog, variables, filenames[i]);
         ExtensionPointHandler.callExtensionPoint(
             LogChannel.UI,
             variables,
             HopGuiExtensionPoint.HopGuiFileOpenedDialog.id,
             openedExtension);
         if (openedExtension.filename != null) {
-          filename = openedExtension.filename;
+          filenames[i] = openedExtension.filename;
         }
       } catch (Exception xe) {
         LogChannel.UI.logError("Error handling extension point 
'HopGuiFileOpenDialog'", xe);
       }
+    }
 
-      if (textVar != null) {
-        textVar.setText(filename);
-      }
+    if (textVar != null) {
+      textVar.setText(filenames[0]);
     }
-    return filename;
+
+    return filenames;
   }
 
-  private static String buildFilename(String filterPath, String fileName) {
+  static String buildFilename(String filterPath, String fileName) {
     if (StringUtils.isEmpty(filterPath)) {
       return fileName;
     }
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/IFileDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/IFileDialog.java
index b204b09462..0358750e48 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/IFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/IFileDialog.java
@@ -33,4 +33,26 @@ public interface IFileDialog {
   String open();
 
   void setFilterPath(String filterPath);
+
+  /**
+   * Allow the user to select more than one file. This needs to be set before 
the dialog is opened.
+   * Dialogs that can only ever select a single file simply ignore this.
+   *
+   * @param multiSelection true to allow selecting multiple files
+   */
+  default void setMultiSelection(boolean multiSelection) {
+    // Single-selection dialogs have nothing to do here.
+  }
+
+  /**
+   * The files that were selected, as full paths. This is only filled in when 
the dialog allows
+   * multi-selection and the user actually selected more than one file. In 
every other case an empty
+   * array is returned and {@link #getFileName()} (relative to {@link 
#getFilterPath()}) is the
+   * selection.
+   *
+   * @return the full paths of the selected files, never null
+   */
+  default String[] getFileNames() {
+    return new String[0];
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/NativeFileDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/NativeFileDialog.java
index aeb477505b..35cee18a5d 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/NativeFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/NativeFileDialog.java
@@ -57,6 +57,32 @@ public class NativeFileDialog implements IFileDialog {
     return fileDialog.getFileName();
   }
 
+  /**
+   * The native dialog needs the SWT.MULTI style at creation time, so 
multi-selection is decided by
+   * whoever builds the {@link FileDialog}. Selecting multiple files in a 
dialog created without
+   * that style is simply not possible, which is why this is a no-op.
+   */
+  @Override
+  public void setMultiSelection(boolean multiSelection) {
+    // See BaseDialog#presentFileDialog: the style is passed to the FileDialog 
constructor.
+  }
+
+  @Override
+  public String[] getFileNames() {
+    String[] names = fileDialog.getFileNames();
+    if (names == null || names.length < 2) {
+      // Not a multi-selection: the caller falls back on 
getFilterPath()/getFileName().
+      return new String[0];
+    }
+    // SWT returns the base names only, relative to the filter path.
+    String filterPath = fileDialog.getFilterPath();
+    String[] filenames = new String[names.length];
+    for (int i = 0; i < names.length; i++) {
+      filenames[i] = BaseDialog.buildFilename(filterPath, names[i]);
+    }
+    return filenames;
+  }
+
   @Override
   public String open() {
     return fileDialog.open();
diff --git a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
index acdaa075b9..76ad5d0cf9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
@@ -24,7 +24,9 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.io.FilenameUtils;
@@ -201,6 +203,12 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
   @Setter @Getter private boolean savingFile;
   private boolean folderAndFile;
 
+  /** Set before opening the dialog to let the user select more than one file. 
*/
+  private boolean multiSelection;
+
+  /** Full paths of the files that were selected when more than one file was 
picked. */
+  private String[] selectedFilenames;
+
   @Setter @Getter private String saveFilename;
 
   private int sortIndex;
@@ -539,7 +547,9 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
 
     SashForm browseSash = new SashForm(browserComposite, SWT.VERTICAL);
 
-    wBrowser = new HopTree(browseSash, SWT.SINGLE | SWT.H_SCROLL | 
SWT.V_SCROLL);
+    wBrowser =
+        new HopTree(
+            browseSash, (multiSelection ? SWT.MULTI : SWT.SINGLE) | 
SWT.H_SCROLL | SWT.V_SCROLL);
     PropsUi.setLook(wBrowser);
     wBrowser.setHeaderVisible(true);
     wBrowser.setLinesVisible(false);
@@ -698,6 +708,13 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
 
   private void okButton() {
     try {
+      // When multiple files are selected in the browser they take precedence 
over the single
+      // filename in the text field.
+      //
+      if (okMultipleSelectedFiles()) {
+        return;
+      }
+
       activeFileObject = HopVfs.getFileObject(wFilename.getText(), variables);
 
       if (!this.browsingDirectories && activeFileObject.isFolder() && 
!this.folderAndFile) {
@@ -725,7 +742,63 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     }
   }
 
+  /**
+   * Close the dialog with all the files that are selected in the browser. 
Folders in the selection
+   * are ignored: they are navigation targets, not a choice.
+   *
+   * @return true if the dialog handled the selection in the browser and is 
closing
+   */
+  private boolean okMultipleSelectedFiles() throws FileSystemException {
+    if (!multiSelection) {
+      return false;
+    }
+    java.util.List<FileObject> selectedFiles = getSelectedFileObjects();
+    if (selectedFiles.size() < 2) {
+      return false;
+    }
+
+    java.util.List<FileObject> files = new ArrayList<>();
+    for (FileObject selectedFile : selectedFiles) {
+      if (!selectedFile.isFolder()) {
+        files.add(selectedFile);
+      }
+    }
+
+    if (files.isEmpty()) {
+      // Only folders were selected: put the first one in the filename field 
and let the regular
+      // handling navigate into it.
+      //
+      showFilename(selectedFiles.get(0));
+      return false;
+    }
+
+    activeFileObject = files.get(0);
+    if (files.size() > 1) {
+      java.util.List<String> filenames = new ArrayList<>();
+      for (FileObject file : files) {
+        filenames.add(HopVfs.getFilename(file));
+      }
+      selectedFilenames = filenames.toArray(new String[0]);
+    }
+    ok();
+    return true;
+  }
+
   private void enteredFilenameOrFolder() {
+    // Hitting enter with multiple files selected opens them all.
+    //
+    try {
+      if (okMultipleSelectedFiles()) {
+        return;
+      }
+    } catch (FileSystemException e) {
+      showError(
+          BaseMessages.getString(
+              PKG, "HopVfsFileDialog.ParsingFilename.Error.Message", 
wFilename.getText()),
+          e);
+      return;
+    }
+
     if (StringUtils.isNotEmpty(saveFilename)) {
       try {
         FileObject fullObject = HopVfs.getFileObject(wFilename.getText(), 
variables);
@@ -754,19 +827,67 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     return fileObjectsMap.get(path);
   }
 
+  /**
+   * All the files and folders selected in the browser. The order in which SWT 
reports a selection
+   * is platform dependent, so we walk the tree to always return them in the 
order they are listed.
+   */
+  private java.util.List<FileObject> getSelectedFileObjects() {
+    TreeItem[] selection = wBrowser.getSelection();
+    if (selection == null || selection.length == 0) {
+      return Collections.emptyList();
+    }
+    Set<TreeItem> selectedItems = new HashSet<>(Arrays.asList(selection));
+    java.util.List<FileObject> fileObjects = new ArrayList<>();
+    collectSelectedFileObjects(wBrowser.getItems(), selectedItems, 
fileObjects);
+    return fileObjects;
+  }
+
+  private void collectSelectedFileObjects(
+      TreeItem[] items, Set<TreeItem> selectedItems, 
java.util.List<FileObject> fileObjects) {
+    for (TreeItem item : items) {
+      if (selectedItems.contains(item)) {
+        FileObject fileObject = fileObjectsMap.get(getTreeItemPath(item));
+        if (fileObject != null) {
+          fileObjects.add(fileObject);
+        }
+      }
+      collectSelectedFileObjects(item.getItems(), selectedItems, fileObjects);
+    }
+  }
+
   /**
    * Something is selected in the browser
    *
    * @param event that was triggered
    */
   private void fileSelected(Event event) {
-    FileObject selectedFile = getSelectedFileObject();
-    if (selectedFile != null) {
-      showFilename(selectedFile);
+    java.util.List<FileObject> selectedFiles = getSelectedFileObjects();
+    if (selectedFiles.size() > 1) {
+      showFilenames(selectedFiles);
+    } else if (!selectedFiles.isEmpty()) {
+      showFilename(selectedFiles.get(0));
     }
     updateSelection();
   }
 
+  /**
+   * Show the names of all selected files, the way a native file dialog does, 
and report the size of
+   * the selection in the details pane.
+   */
+  private void showFilenames(java.util.List<FileObject> fileObjects) {
+    StringBuilder names = new StringBuilder();
+    for (FileObject fileObject : fileObjects) {
+      if (!names.isEmpty()) {
+        names.append(" ");
+      }
+      
names.append("\"").append(fileObject.getName().getBaseName()).append("\"");
+    }
+    wFilename.setText(names.toString());
+    showDetails(
+        BaseMessages.getString(
+            PKG, "HopVfsFileDialog.FileInfo.SelectedFiles", "" + 
fileObjects.size()));
+  }
+
   private void showFilename(FileObject fileObject) {
     try {
       wFilename.setText(HopVfs.getFilename(fileObject));
@@ -1793,6 +1914,16 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     this.fileName = fileName;
   }
 
+  @Override
+  public void setMultiSelection(boolean multiSelection) {
+    this.multiSelection = multiSelection;
+  }
+
+  @Override
+  public String[] getFileNames() {
+    return selectedFilenames == null ? new String[0] : 
selectedFilenames.clone();
+  }
+
   /**
    * @param filterExtensions The filterExtensions to set
    */
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
index 4b22a44848..ca90740fa3 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
@@ -28,6 +28,7 @@ import java.util.Set;
 import lombok.Getter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
 import org.apache.hop.core.RowMetaAndData;
 import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.exception.HopException;
@@ -83,26 +84,57 @@ public class HopGuiFileDelegate {
 
   public void fileOpen() {
     try {
-      // Ask for the file name
+      // Ask for the file names
       // Check in the registry for extensions and names...
       //
       HopFileTypeRegistry fileRegistry = HopFileTypeRegistry.getInstance();
 
-      String filename =
-          BaseDialog.presentFileDialog(
+      String[] filenames =
+          BaseDialog.presentMultiFileDialog(
               hopGui.getShell(),
+              hopGui.getVariables(),
               fileRegistry.getFilterExtensions(),
               fileRegistry.getFilterNames(),
               true);
-      if (filename == null) {
+      if (filenames.length == 0) {
         return;
       }
-      fileOpen(hopGui.getVariables().resolve(filename));
+      fileOpen(filenames);
     } catch (Exception e) {
       new ErrorDialog(hopGui.getActiveShell(), CONST_ERROR, "Error opening 
file", e);
     }
   }
 
+  /**
+   * Open all the given files. The files that can't be opened are collected 
and reported once, so a
+   * single bad file doesn't stop the others from opening.
+   *
+   * @param filenames the files to open
+   */
+  public void fileOpen(String[] filenames) {
+    List<String> errors = new ArrayList<>();
+
+    for (int i = 0; i < filenames.length; i++) {
+      String filename = hopGui.getVariables().resolve(filenames[i]);
+      try {
+        // Only bring the perspective of the last file to the front: switching 
for every file in
+        // between simply flickers.
+        //
+        fileOpen(filename, i == filenames.length - 1);
+      } catch (Exception e) {
+        errors.add(filename + " : " + e.getMessage());
+        hopGui.getLog().logError("Error opening file '" + filename + "'", e);
+      }
+    }
+
+    if (!errors.isEmpty()) {
+      MessageBox messageBox = new MessageBox(hopGui.getActiveShell(), 
SWT.ICON_ERROR | SWT.OK);
+      messageBox.setText(CONST_ERROR);
+      messageBox.setMessage("Error opening file:" + Const.CR + 
String.join(Const.CR, errors));
+      messageBox.open();
+    }
+  }
+
   public IHopFileTypeHandler fileOpen(String filename) throws Exception {
     return fileOpen(filename, true);
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index a181bb4a40..8895722479 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -31,6 +31,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Function;
 import lombok.Getter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.Strings;
@@ -628,7 +629,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     toolBar.pack();
     PropsUi.setLook(toolBar, Props.WIDGET_STYLE_TOOLBAR);
 
-    tree = new Tree(composite, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    tree = new Tree(composite, SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL);
     tree.setHeaderVisible(false);
     tree.addListener(SWT.Selection, event -> updateSelection());
     tree.addListener(SWT.DefaultSelection, this::openFile);
@@ -667,7 +668,9 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           TreeItem[] selection = tree.getSelection();
           TreeItemFolder tif =
               selection.length == 1 ? (TreeItemFolder) selection[0].getData() 
: null;
-          boolean openSupported = tif != null && (tif.folder || 
tif.fileType.supportsOpening());
+          // Opening works on the whole selection, everything else needs 
exactly one file.
+          //
+          boolean openSupported = isOpenSupported(selection);
           MenuItem openItem = menuWidgets.findMenuItem(CONTEXT_MENU_OPEN);
           if (openItem != null) {
             openItem.setEnabled(openSupported);
@@ -686,6 +689,16 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
             renameItem.setEnabled(selection.length == 1);
           }
 
+          MenuItem deleteItem = menuWidgets.findMenuItem(CONTEXT_MENU_DELETE);
+          if (deleteItem != null) {
+            deleteItem.setEnabled(selection.length == 1);
+          }
+
+          MenuItem createFolderItem = 
menuWidgets.findMenuItem(CONTEXT_MENU_CREATE_FOLDER);
+          if (createFolderItem != null) {
+            createFolderItem.setEnabled(selection.length == 1);
+          }
+
           // Finding references is only meaningful for a single pipeline or 
workflow file.
           //
           MenuItem findReferencesItem = 
menuWidgets.findMenuItem(CONTEXT_MENU_FIND_REFERENCES);
@@ -735,19 +748,25 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
           @Override
           public void dragStart(DragSourceEvent event) {
-            ExplorerFile file = getSelectedFile();
+            List<ExplorerFile> files = getSelectedFiles();
             String metadataFolder = 
hopGui.getVariables().getVariable(Const.HOP_METADATA_FOLDER);
             // Avoid moving root folder, metadata folder or hidden file (like 
.git)
-            if (file == null
-                || file.getFilename().equals(rootFolder)
-                || file.getFilename().contains(metadataFolder)
-                || file.getName().startsWith(".")) {
+            if (files.isEmpty()) {
               event.doit = false;
               return;
             }
+            for (ExplorerFile file : files) {
+              if (file.getFilename().equals(rootFolder)
+                  || file.getFilename().contains(metadataFolder)
+                  || file.getName().startsWith(".")) {
+                event.doit = false;
+                return;
+              }
+            }
 
-            // Used by dragOver
-            dragFile = file.getFilename();
+            // Used by dragOver. The files are dragged from the same folder, 
so the first one is
+            // enough to keep them from being dropped onto their own parent.
+            dragFile = files.get(0).getFilename();
 
             // Set an explicit drag image to avoid macOS NPE in 
TreeDragSourceEffect when the
             // native side requests the default tree drag image 
(dragImageFromListener.handle null).
@@ -780,8 +799,9 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           @Override
           public void dragSetData(DragSourceEvent event) {
             if (fileTransfer.isSupportedType(event.dataType)) {
-              event.doit = true;
-              event.data = new String[] {getSelectedFile().getFilename()};
+              List<ExplorerFile> files = getSelectedFiles();
+              event.doit = !files.isEmpty();
+              event.data = 
files.stream().map(ExplorerFile::getFilename).toArray(String[]::new);
             }
           }
 
@@ -1002,6 +1022,23 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
         });
   }
 
+  /** Every selected item needs to be openable before we offer to open the 
selection. */
+  private boolean isOpenSupported(TreeItem[] selection) {
+    if (selection == null || selection.length == 0) {
+      return false;
+    }
+    for (TreeItem item : selection) {
+      TreeItemFolder tif = (TreeItemFolder) item.getData();
+      if (tif == null || tif.fileType == null) {
+        return false;
+      }
+      if (!tif.folder && !tif.fileType.supportsOpening()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
   private void openFile(Event event) {
     if (event.item instanceof TreeItem item) {
       TreeItemFolder tif = (TreeItemFolder) item.getData();
@@ -2544,6 +2581,22 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     return null;
   }
 
+  /** All the files selected in the tree, in the order the tree shows them. */
+  public List<ExplorerFile> getSelectedFiles() {
+    TreeItem[] selection = tree.getSelection();
+    if (selection == null) {
+      return Collections.emptyList();
+    }
+    List<ExplorerFile> files = new ArrayList<>();
+    for (TreeItem item : selection) {
+      TreeItemFolder tif = (TreeItemFolder) item.getData();
+      if (tif != null) {
+        files.add(new ExplorerFile(tif.name, tif.path, tif.fileType));
+      }
+    }
+    return files;
+  }
+
   public IHopFileTypeHandler findFileTypeHandlerByFilename(String filename) {
     if (filename != null) {
       for (TabItemHandler item : items) {
@@ -2889,7 +2942,11 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     if (selection == null || selection.length == 0) {
       return;
     }
-    openFile(selection[0]);
+    // Open every selected file. Selected folders simply expand or collapse.
+    //
+    for (TreeItem item : selection) {
+      openFile(item);
+    }
   }
 
   @GuiMenuElement(
@@ -2899,7 +2956,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       label = "i18n::ExplorerPerspective.Menu.OpenInExplorer")
   public void openFileInExplorer() {
     TreeItem[] selection = tree.getSelection();
-    if (selection == null || selection.length == 0) {
+    if (selection == null || selection.length != 1) {
       return;
     }
 
@@ -2932,7 +2989,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   public void createFolder() {
 
     TreeItem[] selection = tree.getSelection();
-    if (selection == null || selection.length == 0) {
+    if (selection == null || selection.length != 1) {
       return;
     }
     TreeItem item = selection[0];
@@ -3068,9 +3125,12 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @GuiKeyboardShortcut(key = SWT.DEL)
   @GuiOsxKeyboardShortcut(key = SWT.DEL)
   public void deleteFile() {
-    // Shortcut only fires when focus is in file explorer
+    // Shortcut only fires when focus is in file explorer.
+    // Deleting is deliberately limited to a single file: the confirmation 
(and the reference
+    // check behind it) is written for one file at a time.
+    //
     TreeItem[] selection = tree.getSelection();
-    if (selection == null || selection.length == 0) {
+    if (selection == null || selection.length != 1) {
       return;
     }
 
@@ -3093,7 +3153,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @GuiOsxKeyboardShortcut(key = SWT.F2)
   public void renameFile() {
     TreeItem[] selection = tree.getSelection();
-    if (selection == null || selection.length == 0) {
+    if (selection == null || selection.length != 1) {
       return;
     }
     renameFile(selection[0]);
@@ -3106,12 +3166,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       label = "i18n::ExplorerPerspective.Menu.CopyName",
       separator = true)
   public void copyFileName() {
-    TreeItem[] selection = tree.getSelection();
-    if (selection == null || selection.length == 0) {
-      return;
-    }
-    TreeItemFolder folder = (TreeItemFolder) selection[0].getData();
-    GuiResource.getInstance().toClipboard(folder.name);
+    copyToClipboard(tif -> tif.name);
   }
 
   @GuiMenuElement(
@@ -3120,13 +3175,25 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       id = CONTEXT_MENU_COPY_PATH,
       label = "i18n::ExplorerPerspective.Menu.CopyPath")
   public void copyFilePath() {
+    copyToClipboard(tif -> tif.path);
+  }
+
+  /** Copy one line per selected file to the clipboard. */
+  private void copyToClipboard(Function<TreeItemFolder, String> valueGetter) {
     TreeItem[] selection = tree.getSelection();
     if (selection == null || selection.length == 0) {
       return;
     }
-
-    TreeItemFolder folder = (TreeItemFolder) selection[0].getData();
-    GuiResource.getInstance().toClipboard(folder.path);
+    List<String> values = new ArrayList<>();
+    for (TreeItem item : selection) {
+      TreeItemFolder tif = (TreeItemFolder) item.getData();
+      if (tif != null) {
+        values.add(valueGetter.apply(tif));
+      }
+    }
+    if (!values.isEmpty()) {
+      GuiResource.getInstance().toClipboard(String.join(Const.CR, values));
+    }
   }
 
   @GuiMenuElement(
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
index 328bad2bbc..a3258baa40 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
@@ -33,6 +33,7 @@ HopVfsFileDialog.DeleteFile.Tooltip.Message=Delete selected 
file (DEL)
 HopVfsFileDialog.DrillInto.Error.Message=Error drilling into archive: ''{0}''
 HopVfsFileDialog.DrillInto.Tooltip.Message=Drill into the selected archive 
(zip, jar, tar, …). Double-click does the same; use OK to select the archive 
file itself.
 HopVfsFileDialog.File.Size.Label=Size
+HopVfsFileDialog.FileInfo.SelectedFiles={0} files selected
 HopVfsFileDialog.FileInfo.Tooltip.Attributes=Attributes: {0}
 HopVfsFileDialog.FileInfo.Tooltip.Executable=Executable: {0}
 HopVfsFileDialog.FileInfo.Tooltip.Folder=Folder: {0}

Reply via email to