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}