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 0eb321ec80 Issue apache#8135 [Feature Request]: Add quick file
creation to the Hop file browser (#8190)
0eb321ec80 is described below
commit 0eb321ec80ad5f1d68b6a752810a3da0b04c8e31
Author: Sergio Ramazzina <[email protected]>
AuthorDate: Mon Aug 31 15:25:18 2026 +0200
Issue apache#8135 [Feature Request]: Add quick file creation to the Hop
file browser (#8190)
---
.../pages/hop-gui/perspective-file-explorer.adoc | 7 +-
.../transforms/types/JsonExplorerFileType.java | 1 +
.../transforms/types/PythonExplorerFileType.java | 1 +
.../transforms/types/TextExplorerFileType.java | 1 +
.../types/PythonExplorerFileTypeTest.java | 3 +-
.../perspective/explorer/CreateFileDialog.java | 205 +++++++++++++++++++
.../perspective/explorer/ExplorerCreateUtils.java | 141 +++++++++++++
.../perspective/explorer/ExplorerPerspective.java | 226 ++++++++++++++++++++-
.../file/types/sql/SqlExplorerFileType.java | 1 +
.../file/types/xml/XmlExplorerFileType.java | 1 +
.../file/types/yaml/YamlExplorerFileType.java | 1 +
.../explorer/messages/messages_en_US.properties | 17 ++
.../explorer/ExplorerCreateUtilsTest.java | 169 +++++++++++++++
.../file/types/yaml/YamlExplorerFileTypeTest.java | 2 +-
14 files changed, 769 insertions(+), 7 deletions(-)
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 feaa000341..cbc2e8f652 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
@@ -48,6 +48,8 @@ image::hop-gui/perspective-explorer-toolbar-items.png[File
explorer toolbar item
* Refresh: Refresh the file list.
* Show or hide files: Show or hide files or directories.
+New pipelines, workflows and files are created from the file explorer's
right-click menu rather than the toolbar; see the "Right-click menu options"
section below.
+
== Toggling the File Explorer Panel
When the File Explorer perspective is already active, clicking the File
Explorer button in the perspectives toolbar (left side) will toggle the
visibility of the file explorer panel (project tree). This allows you to
maximize the workspace area when you don't need to see the file tree.
@@ -95,7 +97,10 @@ Small information icons will be added to the various
transforms, actions and hop
=== Right-click menu options
The right-click menu in Apache Hop Git integration offers several useful
options to help manage your files and Git operations directly from the
interface. Here’s a breakdown of each option:
-* Create folder: Create a new folder within the selected directory. This is
useful for organizing workflows, pipelines, and other project resources.
+* Create pipeline: Create a new, empty pipeline inside the selected folder and
open it. Only available when a folder is selected.
+* Create workflow: Create a new, empty workflow inside the selected folder and
open it. Only available when a folder is selected.
+* Create file: Create a new, empty file inside the selected folder, choosing
its name and file type, and open it. Only available when a folder is selected.
+* Create folder: Create a new folder within the selected directory. This is
useful for organizing workflows, pipelines, and other project resources. Only
available when a folder is selected.
* Expand all folders: Reveals all nested folders in the project directory
tree. Useful for getting a full overview of your project structure without
opening folders one by one.
* Collapse all folders: Closes all expanded folders, showing only the
top-level directory. Helps reset the view or reduce visual clutter.
* Open: Opens the selected file or folder. For workflows and pipelines, it
launches them in the Data Orchestration perspective, while other file types
will open in a new tab.
diff --git
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
index c9d11197b3..c7454e4427 100644
---
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
+++
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
@@ -43,6 +43,7 @@ public class JsonExplorerFileType extends
BaseTextExplorerFileType<JsonExplorerF
new String[] {"*.json"},
new String[] {"JSON files"},
FileTypeCapabilities.getCapabilities(
+ IHopFileType.CAPABILITY_NEW,
IHopFileType.CAPABILITY_SAVE,
IHopFileType.CAPABILITY_SAVE_AS,
IHopFileType.CAPABILITY_CLOSE,
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
index 3cef1040c7..35e5b05ca1 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
@@ -44,6 +44,7 @@ public class PythonExplorerFileType
new String[] {"*.py"},
new String[] {"Python scripts"},
FileTypeCapabilities.getCapabilities(
+ IHopFileType.CAPABILITY_NEW,
IHopFileType.CAPABILITY_SAVE,
IHopFileType.CAPABILITY_SAVE_AS,
IHopFileType.CAPABILITY_CLOSE,
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
index 95fbee298c..7023e63de0 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
@@ -43,6 +43,7 @@ public class TextExplorerFileType extends
BaseTextExplorerFileType<TextExplorerF
new String[] {"*.txt"},
new String[] {"TXT files"},
FileTypeCapabilities.getCapabilities(
+ IHopFileType.CAPABILITY_NEW,
IHopFileType.CAPABILITY_SAVE,
IHopFileType.CAPABILITY_SAVE_AS,
IHopFileType.CAPABILITY_CLOSE,
diff --git
a/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileTypeTest.java
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileTypeTest.java
index 1e0b25af3a..82fa0c7ef4 100644
---
a/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileTypeTest.java
+++
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileTypeTest.java
@@ -18,7 +18,6 @@
package org.apache.hop.pipeline.transforms.types;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -72,7 +71,7 @@ class PythonExplorerFileTypeTest {
assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_FILE_HISTORY));
assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_COPY));
assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_SELECT));
- assertFalse(fileType.hasCapability(IHopFileType.CAPABILITY_NEW));
+ assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_NEW));
}
@Test
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/CreateFileDialog.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/CreateFileDialog.java
new file mode 100644
index 0000000000..bce3828e7a
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/CreateFileDialog.java
@@ -0,0 +1,205 @@
+/*
+ * 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.hopgui.perspective.explorer;
+
+import java.util.List;
+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.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+/** Asks for a file name and a file type, and reports the full path of the
file to create. */
+public class CreateFileDialog extends Dialog {
+ private static final Class<?> PKG = ExplorerPerspective.class;
+
+ private final String folderPath;
+ private final List<IHopFileType> fileTypes;
+ private final PropsUi props;
+
+ private Shell shell;
+ private Text wName;
+ private Combo wType;
+ private Label wPreview;
+ private Button wOk;
+
+ private String filePath;
+ private IHopFileType selectedFileType;
+
+ public CreateFileDialog(Shell parent, String folderPath, List<IHopFileType>
fileTypes) {
+ super(parent, SWT.NONE);
+ this.folderPath = folderPath;
+ this.fileTypes = fileTypes;
+ this.props = PropsUi.getInstance();
+ }
+
+ /** Opens the dialog. Returns the full path of the file to create, or null
when cancelled. */
+ public String open() {
+ Shell parent = getParent();
+
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE |
SWT.APPLICATION_MODAL | SWT.SHEET);
+ PropsUi.setLook(shell);
+ shell.setImage(GuiResource.getInstance().getImageHopUi());
+ shell.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.CreateFile.Header"));
+
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ shell.setLayout(formLayout);
+
+ int margin = PropsUi.getMargin();
+
+ Label wlName = new Label(shell, SWT.NONE);
+ wlName.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.CreateFile.Name.Label"));
+ PropsUi.setLook(wlName);
+ FormData fdlName = new FormData();
+ fdlName.left = new FormAttachment(0, 0);
+ fdlName.top = new FormAttachment(0, margin);
+ wlName.setLayoutData(fdlName);
+
+ wName = new Text(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wName);
+ FormData fdName = new FormData();
+ fdName.left = new FormAttachment(0, 0);
+ fdName.top = new FormAttachment(wlName, margin);
+ fdName.right = new FormAttachment(100, -margin);
+ wName.setLayoutData(fdName);
+ wName.addModifyListener(e -> updateState());
+
+ Label wlType = new Label(shell, SWT.NONE);
+ wlType.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.CreateFile.Type.Label"));
+ PropsUi.setLook(wlType);
+ FormData fdlType = new FormData();
+ fdlType.left = new FormAttachment(0, 0);
+ fdlType.top = new FormAttachment(wName, margin);
+ wlType.setLayoutData(fdlType);
+
+ wType = new Combo(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER |
SWT.READ_ONLY);
+ PropsUi.setLook(wType);
+ for (IHopFileType fileType : fileTypes) {
+ wType.add(fileType.getName());
+ }
+ if (!fileTypes.isEmpty()) {
+ wType.select(0);
+ }
+ FormData fdType = new FormData();
+ fdType.left = new FormAttachment(0, 0);
+ fdType.top = new FormAttachment(wlType, margin);
+ fdType.right = new FormAttachment(100, -margin);
+ wType.setLayoutData(fdType);
+ wType.addListener(SWT.Selection, e -> updateState());
+
+ Label wlPreview = new Label(shell, SWT.NONE);
+ wlPreview.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.CreateFile.Preview.Label"));
+ PropsUi.setLook(wlPreview);
+ FormData fdlPreview = new FormData();
+ fdlPreview.left = new FormAttachment(0, 0);
+ fdlPreview.top = new FormAttachment(wType, margin);
+ wlPreview.setLayoutData(fdlPreview);
+
+ wPreview = new Label(shell, SWT.NONE);
+ PropsUi.setLook(wPreview);
+ FormData fdPreview = new FormData();
+ fdPreview.left = new FormAttachment(0, 0);
+ fdPreview.top = new FormAttachment(wlPreview, margin);
+ fdPreview.right = new FormAttachment(100, -margin);
+ wPreview.setLayoutData(fdPreview);
+
+ wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString(PKG, "System.Button.OK"));
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+
+ BaseTransformDialog.positionBottomButtons(shell, new Button[] {wOk,
wCancel}, margin, wPreview);
+
+ wOk.addListener(SWT.Selection, e -> ok());
+ wCancel.addListener(SWT.Selection, e -> cancel());
+
+ updateState();
+
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+
+ return filePath;
+ }
+
+ /** The file type picked in the combo. Only meaningful when {@link #open()}
returned a path. */
+ public IHopFileType getSelectedFileType() {
+ return selectedFileType;
+ }
+
+ private IHopFileType currentFileType() {
+ int index = wType.getSelectionIndex();
+ if (index < 0 || index >= fileTypes.size()) {
+ return null;
+ }
+ return fileTypes.get(index);
+ }
+
+ private String currentPath() {
+ IHopFileType fileType = currentFileType();
+ if (fileType == null ||
!ExplorerCreateUtils.isSimpleFileName(wName.getText())) {
+ return null;
+ }
+ String fileName =
+ ExplorerCreateUtils.applyExtension(wName.getText(),
fileType.getDefaultFileExtension());
+ String path = ExplorerCreateUtils.childPath(folderPath, fileName);
+ if (!ExplorerCreateUtils.resolvesInsideFolder(folderPath, path)) {
+ return null;
+ }
+ return path;
+ }
+
+ private void updateState() {
+ String path = currentPath();
+ wPreview.setText(path == null ? "" : path);
+ wOk.setEnabled(path != null);
+ }
+
+ private void ok() {
+ String path = currentPath();
+ if (path == null) {
+ return;
+ }
+ filePath = path;
+ selectedFileType = currentFileType();
+ dispose();
+ }
+
+ private void cancel() {
+ filePath = null;
+ selectedFileType = null;
+ dispose();
+ }
+
+ private void dispose() {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerCreateUtils.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerCreateUtils.java
new file mode 100644
index 0000000000..3b8cbecb9f
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerCreateUtils.java
@@ -0,0 +1,141 @@
+/*
+ * 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.hopgui.perspective.explorer;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopFileException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
+import org.apache.hop.ui.hopgui.file.workflow.HopWorkflowFileType;
+
+/** Helpers behind the "Create file / pipeline / workflow" entries of the
explorer perspective. */
+final class ExplorerCreateUtils {
+
+ private ExplorerCreateUtils() {}
+
+ /**
+ * Append {@code extension} to {@code typedName} unless the name already
ends with it
+ * (case-insensitive). The extension may be given with or without its
leading dot.
+ */
+ static String applyExtension(String typedName, String extension) {
+ String name = typedName == null ? "" : typedName.trim();
+ if (Utils.isEmpty(extension)) {
+ return name;
+ }
+ String dotted = extension.startsWith(".") ? extension : "." + extension;
+ if
(name.toLowerCase(Locale.ROOT).endsWith(dotted.toLowerCase(Locale.ROOT))) {
+ return name;
+ }
+ return name + dotted;
+ }
+
+ /** The file name without its last extension. A leading dot is not treated
as a separator. */
+ static String baseName(String fileName) {
+ if (fileName == null) {
+ return null;
+ }
+ int dot = fileName.lastIndexOf('.');
+ if (dot <= 0) {
+ return fileName;
+ }
+ return fileName.substring(0, dot);
+ }
+
+ /** Join a folder path and a file name with exactly one {@code /} separator.
*/
+ static String childPath(String folderPath, String fileName) {
+ return ExplorerPathUtils.normalizePath(folderPath) + "/" + fileName;
+ }
+
+ /**
+ * The file types offered by the "Create file" dialog: those declaring {@link
+ * IHopFileType#CAPABILITY_NEW}, minus pipelines and workflows which have
their own menu entries.
+ * Sorted by name so the combo is stable.
+ */
+ static List<IHopFileType> creatableFileTypes(List<IHopFileType>
allFileTypes) {
+ List<IHopFileType> creatable = new ArrayList<>();
+ for (IHopFileType fileType : allFileTypes) {
+ if (fileType instanceof HopPipelineFileType || fileType instanceof
HopWorkflowFileType) {
+ continue;
+ }
+ if (fileType.hasCapability(IHopFileType.CAPABILITY_NEW)) {
+ creatable.add(fileType);
+ }
+ }
+ creatable.sort(Comparator.comparing(IHopFileType::getName));
+ return creatable;
+ }
+
+ static boolean fileExists(String path) throws HopFileException {
+ return HopVfs.fileExists(path);
+ }
+
+ /**
+ * True when the name can safely become a direct child of a folder: not
blank, no path separator,
+ * not a relative directory reference, and not ending with a dot (which
Windows silently strips at
+ * creation time, defeating the "never overwrite" guarantee). This is only
the cheap first check;
+ * {@link #resolvesInsideFolder(String, String)} is what actually guards
against traversal.
+ */
+ static boolean isSimpleFileName(String typedName) {
+ if (typedName == null) {
+ return false;
+ }
+ String name = typedName.trim();
+ if (name.isEmpty() || name.contains("/") || name.contains("\\") ||
name.endsWith(".")) {
+ return false;
+ }
+ return !".".equals(name) && !"..".equals(name);
+ }
+
+ /**
+ * True when {@code candidatePath} resolves to a direct child of {@code
folderPath}. Resolving
+ * both sides is what makes this safe: VFS decodes escape sequences and
collapses {@code ..}
+ * segments before the comparison, which inspecting the typed name alone
cannot do.
+ */
+ static boolean resolvesInsideFolder(String folderPath, String candidatePath)
{
+ try {
+ FileObject folder = HopVfs.getFileObject(folderPath);
+ FileObject candidate = HopVfs.getFileObject(candidatePath);
+ FileObject parent = candidate.getParent();
+ return parent != null && parent.getName().equals(folder.getName());
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /** Create an empty file. Refuses to touch an existing one. */
+ static void createEmptyFile(String path) throws HopException {
+ if (fileExists(path)) {
+ throw new HopException("File already exists: " + path);
+ }
+ try {
+ FileObject fileObject = HopVfs.getFileObject(path);
+ fileObject.createFile();
+ } catch (HopException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new HopException("Unable to create file: " + path, e);
+ }
+ }
+}
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 deafce40fb..03d0c9aea4 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
@@ -209,6 +209,12 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
"ExplorerPerspective-Toolbar-10400-Show-hidden";
public static final String TOOLBAR_ITEM_SELECT_OPENED_FILE =
"ExplorerPerspective-Toolbar-10500-Select-opened-file";
+ public static final String CONTEXT_MENU_CREATE_PIPELINE =
+ "ExplorerPerspective-ContextMenu-10010-CreatePipeline";
+ public static final String CONTEXT_MENU_CREATE_WORKFLOW =
+ "ExplorerPerspective-ContextMenu-10020-CreateWorkflow";
+ public static final String CONTEXT_MENU_CREATE_FILE =
+ "ExplorerPerspective-ContextMenu-10030-CreateFile";
public static final String CONTEXT_MENU_CREATE_FOLDER =
"ExplorerPerspective-ContextMenu-10050-CreateFolder";
public static final String CONTEXT_MENU_EXPAND_ALL =
@@ -781,9 +787,20 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
deleteItem.setEnabled(selection.length == 1);
}
- MenuItem createFolderItem =
menuWidgets.findMenuItem(CONTEXT_MENU_CREATE_FOLDER);
- if (createFolderItem != null) {
- createFolderItem.setEnabled(selection.length == 1);
+ // Creating anything only makes sense inside a folder.
+ //
+ boolean folderSelected = selection.length == 1 && tif != null &&
tif.folder;
+ for (String createMenuId :
+ new String[] {
+ CONTEXT_MENU_CREATE_PIPELINE,
+ CONTEXT_MENU_CREATE_WORKFLOW,
+ CONTEXT_MENU_CREATE_FILE,
+ CONTEXT_MENU_CREATE_FOLDER
+ }) {
+ MenuItem createItem = menuWidgets.findMenuItem(createMenuId);
+ if (createItem != null) {
+ createItem.setEnabled(folderSelected);
+ }
}
MenuItem refreshFolderItem =
menuWidgets.findMenuItem(CONTEXT_MENU_REFRESH_FOLDER);
@@ -3118,6 +3135,209 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
}
}
+ /**
+ * The path of the selected folder, or null when the selection is not a
single folder or the user
+ * has no write permission.
+ */
+ private String getSelectedFolderPath() {
+ if (!HopSecurityUi.check(Permission.EXPLORER_WRITE)) {
+ return null;
+ }
+ TreeItem[] selection = tree.getSelection();
+ if (selection == null || selection.length != 1) {
+ return null;
+ }
+ TreeItemFolder tif = (TreeItemFolder) selection[0].getData();
+ if (tif == null || !tif.folder) {
+ return null;
+ }
+ return tif.path;
+ }
+
+ private boolean refuseExistingFile(String path) {
+ try {
+ if (ExplorerCreateUtils.fileExists(path)) {
+ MessageBox box = new MessageBox(getShell(), SWT.ICON_ERROR | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.Error.FileExists.Header"));
+ box.setMessage(
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.FileExists.Message", path));
+ box.open();
+ return true;
+ }
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Header"),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Message", path),
+ e);
+ return true;
+ }
+ return false;
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = CONTEXT_MENU_CREATE_PIPELINE,
+ label = "i18n::ExplorerPerspective.Menu.CreatePipeline",
+ image = "ui/images/pipeline.svg")
+ public void createPipeline() {
+ String folder = getSelectedFolderPath();
+ if (folder == null) {
+ return;
+ }
+ EnterStringDialog dialog =
+ new EnterStringDialog(
+ getShell(),
+ "",
+ BaseMessages.getString(PKG,
"ExplorerPerspective.CreatePipeline.Header"),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.CreatePipeline.Message", folder));
+ String typedName = dialog.open();
+ if (typedName == null) {
+ return;
+ }
+ if (!ExplorerCreateUtils.isSimpleFileName(typedName)) {
+ MessageBox box = new MessageBox(getShell(), SWT.ICON_ERROR | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.Error.InvalidFileName.Header"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG, "ExplorerPerspective.Error.InvalidFileName.Message",
typedName));
+ box.open();
+ return;
+ }
+ String fileName =
+ ExplorerCreateUtils.applyExtension(typedName,
pipelineFileType.getDefaultFileExtension());
+ String filename = ExplorerCreateUtils.childPath(folder, fileName);
+ if (!ExplorerCreateUtils.resolvesInsideFolder(folder, filename)) {
+ MessageBox box = new MessageBox(getShell(), SWT.ICON_ERROR | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.Error.InvalidFileName.Header"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG, "ExplorerPerspective.Error.InvalidFileName.Message",
typedName));
+ box.open();
+ return;
+ }
+ if (refuseExistingFile(filename)) {
+ return;
+ }
+ try {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName(ExplorerCreateUtils.baseName(fileName));
+ pipelineMeta.setMetadataProvider(hopGui.getMetadataProvider());
+ pipelineMeta.setFilename(filename);
+ IHopFileTypeHandler handler = addPipeline(pipelineMeta);
+ handler.save();
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Header"),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Message", filename),
+ e);
+ }
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = CONTEXT_MENU_CREATE_WORKFLOW,
+ label = "i18n::ExplorerPerspective.Menu.CreateWorkflow",
+ image = "ui/images/workflow.svg")
+ public void createWorkflow() {
+ String folder = getSelectedFolderPath();
+ if (folder == null) {
+ return;
+ }
+ EnterStringDialog dialog =
+ new EnterStringDialog(
+ getShell(),
+ "",
+ BaseMessages.getString(PKG,
"ExplorerPerspective.CreateWorkflow.Header"),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.CreateWorkflow.Message", folder));
+ String typedName = dialog.open();
+ if (typedName == null) {
+ return;
+ }
+ if (!ExplorerCreateUtils.isSimpleFileName(typedName)) {
+ MessageBox box = new MessageBox(getShell(), SWT.ICON_ERROR | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.Error.InvalidFileName.Header"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG, "ExplorerPerspective.Error.InvalidFileName.Message",
typedName));
+ box.open();
+ return;
+ }
+ String fileName =
+ ExplorerCreateUtils.applyExtension(typedName,
workflowFileType.getDefaultFileExtension());
+ String filename = ExplorerCreateUtils.childPath(folder, fileName);
+ if (!ExplorerCreateUtils.resolvesInsideFolder(folder, filename)) {
+ MessageBox box = new MessageBox(getShell(), SWT.ICON_ERROR | SWT.OK);
+ box.setText(BaseMessages.getString(PKG,
"ExplorerPerspective.Error.InvalidFileName.Header"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG, "ExplorerPerspective.Error.InvalidFileName.Message",
typedName));
+ box.open();
+ return;
+ }
+ if (refuseExistingFile(filename)) {
+ return;
+ }
+ try {
+ WorkflowMeta workflowMeta = new WorkflowMeta();
+ workflowMeta.setName(ExplorerCreateUtils.baseName(fileName));
+ workflowMeta.setMetadataProvider(hopGui.getMetadataProvider());
+ workflowMeta.setFilename(filename);
+ IHopFileTypeHandler handler = addWorkflow(workflowMeta);
+ handler.save();
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Header"),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Message", filename),
+ e);
+ }
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = CONTEXT_MENU_CREATE_FILE,
+ label = "i18n::ExplorerPerspective.Menu.CreateFile",
+ image = "ui/images/new.svg")
+ public void createFile() {
+ String folder = getSelectedFolderPath();
+ if (folder == null) {
+ return;
+ }
+ List<IHopFileType> creatable =
ExplorerCreateUtils.creatableFileTypes(fileTypes);
+ if (creatable.isEmpty()) {
+ return;
+ }
+ CreateFileDialog dialog = new CreateFileDialog(getShell(), folder,
creatable);
+ String filename = dialog.open();
+ if (Utils.isEmpty(filename)) {
+ return;
+ }
+ if (refuseExistingFile(filename)) {
+ return;
+ }
+ try {
+ ExplorerCreateUtils.createEmptyFile(filename);
+ refresh();
+ IHopFileTypeHandler handler =
+ dialog.getSelectedFileType().openFile(hopGui, filename,
hopGui.getVariables());
+ if (handler != null) {
+ handler.updateGui();
+ hopGui.auditDelegate.writeLastOpenFiles();
+ }
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Header"),
+ BaseMessages.getString(PKG,
"ExplorerPerspective.Error.CreateFile.Message", filename),
+ e);
+ }
+ }
+
@GuiMenuElement(
root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
index 46e62ef8da..75229af5db 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
@@ -43,6 +43,7 @@ public class SqlExplorerFileType extends
BaseTextExplorerFileType<SqlExplorerFil
new String[] {"*.sql"},
new String[] {"SQL files"},
FileTypeCapabilities.getCapabilities(
+ IHopFileType.CAPABILITY_NEW,
IHopFileType.CAPABILITY_SAVE,
IHopFileType.CAPABILITY_SAVE_AS,
IHopFileType.CAPABILITY_CLOSE,
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
index f14ed5b17f..5c975d65b8 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
@@ -43,6 +43,7 @@ public class XmlExplorerFileType extends
BaseTextExplorerFileType<XmlExplorerFil
new String[] {"*.xml"},
new String[] {"XML files"},
FileTypeCapabilities.getCapabilities(
+ IHopFileType.CAPABILITY_NEW,
IHopFileType.CAPABILITY_SAVE,
IHopFileType.CAPABILITY_SAVE_AS,
IHopFileType.CAPABILITY_CLOSE,
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileType.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileType.java
index 76dc936794..147b494e61 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileType.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileType.java
@@ -43,6 +43,7 @@ public class YamlExplorerFileType extends
BaseTextExplorerFileType<YamlExplorerF
new String[] {"*.yml", "*.yaml"},
new String[] {"YAML files", "YAML files"},
FileTypeCapabilities.getCapabilities(
+ IHopFileType.CAPABILITY_NEW,
IHopFileType.CAPABILITY_SAVE,
IHopFileType.CAPABILITY_SAVE_AS,
IHopFileType.CAPABILITY_CLOSE,
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
index 1c87791da8..3af710ed66 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/messages/messages_en_US.properties
@@ -15,6 +15,10 @@
# limitations under the License.
#
+ExplorerPerspective.CreateFile.Header=Create file
+ExplorerPerspective.CreateFile.Name.Label=Name
+ExplorerPerspective.CreateFile.Type.Label=File type
+ExplorerPerspective.CreateFile.Preview.Label=Will be created as
ExplorerPerspective.CreateFolder.Header=Create directory
ExplorerPerspective.CreateFolder.Message=Please enter name of the folder to
create in: ''{0}''
ExplorerPerspective.DeleteFile.Confirmation.Header=Delete file
@@ -82,3 +86,16 @@ ExplorerPerspective.DeleteFile.WithReferences.Message=Found
{0} reference(s) to
ExplorerPerspective.DeleteFile.WithReferences.Button.Details=Details
ExplorerPerspective.DeleteFile.WithReferences.Details.Title=References to {0}
ExplorerPerspective.DeleteFile.WithReferences.Details.Message=The following
files and metadata objects still reference ''{0}'':
+ExplorerPerspective.Menu.CreatePipeline=Create pipeline...
+ExplorerPerspective.Menu.CreateWorkflow=Create workflow...
+ExplorerPerspective.Menu.CreateFile=Create file...
+ExplorerPerspective.CreatePipeline.Header=Create pipeline
+ExplorerPerspective.CreatePipeline.Message=Please enter the name of the
pipeline to create in: ''{0}''
+ExplorerPerspective.CreateWorkflow.Header=Create workflow
+ExplorerPerspective.CreateWorkflow.Message=Please enter the name of the
workflow to create in: ''{0}''
+ExplorerPerspective.Error.CreateFile.Header=Error
+ExplorerPerspective.Error.CreateFile.Message=Error creating file: ''{0}''
+ExplorerPerspective.Error.FileExists.Header=File already exists
+ExplorerPerspective.Error.FileExists.Message=The file ''{0}'' already exists
and will not be overwritten.
+ExplorerPerspective.Error.InvalidFileName.Header=Invalid name
+ExplorerPerspective.Error.InvalidFileName.Message=''{0}'' is not a valid name.
Enter a single name without any path separator.
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerCreateUtilsTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerCreateUtilsTest.java
new file mode 100644
index 0000000000..3f0c9e5f07
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerCreateUtilsTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.hopgui.perspective.explorer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
+import org.apache.hop.ui.hopgui.file.workflow.HopWorkflowFileType;
+import
org.apache.hop.ui.hopgui.perspective.explorer.file.types.sql.SqlExplorerFileType;
+import
org.apache.hop.ui.hopgui.perspective.explorer.file.types.svg.SvgExplorerFileType;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ExplorerCreateUtilsTest {
+
+ @Test
+ void applyExtensionAppendsWhenMissing() {
+ assertEquals("notes.md", ExplorerCreateUtils.applyExtension("notes",
".md"));
+ assertEquals("notes.md", ExplorerCreateUtils.applyExtension("notes",
"md"));
+ assertEquals("notes.md", ExplorerCreateUtils.applyExtension(" notes ",
".md"));
+ }
+
+ @Test
+ void applyExtensionDoesNotDuplicate() {
+ assertEquals("notes.md", ExplorerCreateUtils.applyExtension("notes.md",
".md"));
+ assertEquals("notes.MD", ExplorerCreateUtils.applyExtension("notes.MD",
".md"));
+ }
+
+ @Test
+ void applyExtensionKeepsForeignExtension() {
+ assertEquals("notes.txt.md",
ExplorerCreateUtils.applyExtension("notes.txt", ".md"));
+ }
+
+ @Test
+ void applyExtensionWithoutExtensionReturnsName() {
+ assertEquals("notes", ExplorerCreateUtils.applyExtension("notes", null));
+ assertEquals("notes", ExplorerCreateUtils.applyExtension("notes", ""));
+ }
+
+ @Test
+ void baseNameStripsLastExtension() {
+ assertEquals("notes", ExplorerCreateUtils.baseName("notes.md"));
+ assertEquals("my.notes", ExplorerCreateUtils.baseName("my.notes.md"));
+ assertEquals("notes", ExplorerCreateUtils.baseName("notes"));
+ assertEquals(".hidden", ExplorerCreateUtils.baseName(".hidden"));
+ }
+
+ @Test
+ void childPathJoinsWithSingleSeparator() {
+ assertEquals("/project/notes.md",
ExplorerCreateUtils.childPath("/project", "notes.md"));
+ assertEquals("/project/notes.md",
ExplorerCreateUtils.childPath("/project/", "notes.md"));
+ assertEquals("C:/project/notes.md",
ExplorerCreateUtils.childPath("C:\\project\\", "notes.md"));
+ }
+
+ @Test
+ void creatableFileTypesKeepsOnlyNewCapableAndExcludesPipelineAndWorkflow() {
+ List<IHopFileType> all =
+ List.of(
+ new HopPipelineFileType<PipelineMeta>(),
+ new HopWorkflowFileType<WorkflowMeta>(),
+ new SqlExplorerFileType(),
+ new SvgExplorerFileType());
+
+ List<IHopFileType> creatable = ExplorerCreateUtils.creatableFileTypes(all);
+
+ // SQL is creatable; SVG is not; pipeline and workflow have their own menu
entries.
+ assertEquals(1, creatable.size());
+ assertEquals(new SqlExplorerFileType().getName(),
creatable.get(0).getName());
+ }
+
+ @Test
+ void isSimpleFileNameAcceptsPlainNames() {
+ assertTrue(ExplorerCreateUtils.isSimpleFileName("notes"));
+ assertTrue(ExplorerCreateUtils.isSimpleFileName("notes.md"));
+ assertTrue(ExplorerCreateUtils.isSimpleFileName("my.notes.md"));
+ assertTrue(ExplorerCreateUtils.isSimpleFileName(".hidden"));
+ }
+
+ @Test
+ void isSimpleFileNameRejectsBlankOrPathLikeNames() {
+ assertFalse(ExplorerCreateUtils.isSimpleFileName(null));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName(""));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName(" "));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName("sub/notes"));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName("..\\notes"));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName("../shared/util"));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName("."));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName(".."));
+ }
+
+ @Test
+ void createEmptyFileCreatesTheFile(@TempDir Path tempDir) throws Exception {
+ String path = new File(tempDir.toFile(), "notes.md").getAbsolutePath();
+
+ assertFalse(ExplorerCreateUtils.fileExists(path));
+ ExplorerCreateUtils.createEmptyFile(path);
+
+ assertTrue(ExplorerCreateUtils.fileExists(path));
+ assertEquals(0, new File(path).length());
+ }
+
+ @Test
+ void createEmptyFileRefusesAnExistingFile(@TempDir Path tempDir) throws
Exception {
+ String path = new File(tempDir.toFile(), "notes.md").getAbsolutePath();
+ ExplorerCreateUtils.createEmptyFile(path);
+
+ assertThrows(HopException.class, () ->
ExplorerCreateUtils.createEmptyFile(path));
+ }
+
+ @Test
+ void resolvesInsideFolderAcceptsAPlainChildName(@TempDir Path tempDir) {
+ String folder = tempDir.toFile().getAbsolutePath();
+ String candidate = ExplorerCreateUtils.childPath(folder, "notes.md");
+
+ assertTrue(ExplorerCreateUtils.resolvesInsideFolder(folder, candidate));
+ }
+
+ @Test
+ void
resolvesInsideFolderRejectsPercentEncodedTraversal_regressionFor8135(@TempDir
Path tempDir) {
+ // A typed name of "%2e%2e%2fsecret" passes isSimpleFileName (it contains
no literal "/" or
+ // "\"), but VFS decodes the %XX escapes before collapsing ".." segments,
so the composed path
+ // actually resolves to a sibling of the folder, not a child of it. This
is the reported
+ // bypass: a blacklist on the raw typed name cannot catch it, only
resolving the path can.
+ String folder = tempDir.toFile().getAbsolutePath();
+ String candidate = ExplorerCreateUtils.childPath(folder,
"%2e%2e%2fsecret");
+
+ assertFalse(ExplorerCreateUtils.resolvesInsideFolder(folder, candidate));
+ }
+
+ @Test
+ void resolvesInsideFolderRejectsTheParentDirectoryItself(@TempDir Path
tempDir) {
+ String folder = tempDir.toFile().getAbsolutePath();
+ String candidate = ExplorerCreateUtils.childPath(folder, "..");
+
+ assertFalse(ExplorerCreateUtils.resolvesInsideFolder(folder, candidate));
+ }
+
+ @Test
+ void isSimpleFileNameRejectsATrailingDotButAcceptsHiddenFiles() {
+ assertFalse(ExplorerCreateUtils.isSimpleFileName("notes."));
+ assertFalse(ExplorerCreateUtils.isSimpleFileName("notes.md."));
+ assertTrue(ExplorerCreateUtils.isSimpleFileName(".hidden"));
+ }
+}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileTypeTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileTypeTest.java
index 6523a3e922..7cbbc1bf31 100644
---
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileTypeTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/yaml/YamlExplorerFileTypeTest.java
@@ -95,7 +95,7 @@ class YamlExplorerFileTypeTest {
assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_PASTE));
assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_SELECT));
assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_SEARCH));
- assertFalse(fileType.hasCapability(IHopFileType.CAPABILITY_NEW));
+ assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_NEW));
assertFalse(fileType.hasCapability(IHopFileType.CAPABILITY_START));
}