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));
   }
 

Reply via email to