You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/09/09 15:55:18 UTC

(hop) branch main updated: Add browser-native file operations to Hop Web (#8276)

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

mattcasters 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 7e6a26ec7d Add browser-native file operations to Hop Web (#8276)
7e6a26ec7d is described below

commit 7e6a26ec7da696ea6ba0c618ec83ac25b7e51c18
Author: Michael <58...@users.noreply.github.com>
AuthorDate: Wed Sep 9 10:55:12 2026 -0500

    Add browser-native file operations to Hop Web (#8276)
    
    * Add browser-native file operations to Hop Web
    
    * Address review feedback on browser file transfers
    
    * Skip last-used Kettle import source for File Browser ZIP uploads
    
    File Browser ZIP import extracts into a hop-web-user-files session temp folder that is deleted when the dialog closes. Do not persist that path, ignore an already-stored temp last-used value, and remember a real folder only if the user browsed away from the upload.
    
    ---------
    
    Co-authored-by: Michael <michaaels>
    Co-authored-by: mattcasters <ma...@gmail.com>
---
 assemblies/plugins/pom.xml                         |  12 +-
 assemblies/web/pom.xml                             |  21 +-
 assemblies/web/src/assembly/assembly.xml           |  11 +
 .../java/org/apache/hop/core/vfs/HopVfsTest.java   |  26 +
 .../modules/ROOT/pages/hop-gui/hop-web.adoc        |  25 +
 .../apache/hop/imports/gui/HopImportGuiPlugin.java |  27 +-
 .../hop/imports/kettle/KettleImportDialog.java     |  72 ++-
 .../imports/gui/messages/messages_en_US.properties |   3 +
 .../imports/gui/messages/messages_es_ES.properties |   6 +-
 .../kettle/messages/messages_en_US.properties      |   3 +
 .../kettle/messages/messages_es_ES.properties      |   5 +-
 .../hop/imports/kettle/KettleImportDialogTest.java | 217 +++++++
 .../apache/hop/marketplace/install/HopHome.java    |  32 +
 .../apache/hop/marketplace/optional-plugins.yaml   |   6 -
 .../hop/marketplace/install/HopHomeTest.java       |  41 ++
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java | 197 +++---
 rap/pom.xml                                        |   5 +
 .../apache/hop/ui/hopgui/HopWebUserFilePlugin.java | 666 +++++++++++++++++++++
 .../org/apache/hop/ui/hopgui/UserFileTransfer.java | 358 +++++++++++
 .../hop/ui/hopgui/HopWebUserFilePluginTest.java    | 286 +++++++++
 .../apache/hop/ui/hopgui/UserFileTransferTest.java | 649 ++++++++++++++++++++
 .../org/apache/hop/ui/core/gui/GuiMenuWidgets.java | 157 +++--
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  77 +++
 .../hop/ui/hopgui/HopWebUserFileMenuState.java     |  55 ++
 .../ui/hopgui/delegates/HopGuiFileDelegate.java    |  51 +-
 .../ui/hopgui/messages/messages_en_US.properties   |  13 +
 .../ui/hopgui/messages/messages_es_ES.properties   |  13 +
 .../src/main/resources/ui/images}/kettle-logo.svg  |  36 +-
 .../hop/ui/hopgui/HopGuiWebUserFileMenuTest.java   |  75 +++
 29 files changed, 2953 insertions(+), 192 deletions(-)

diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 398e0f2ed0..b9eb1e5152 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -642,6 +642,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-misc-import</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-misc-mail</artifactId>
@@ -1665,12 +1671,6 @@
                     <version>${project.version}</version>
                     <type>zip</type>
                 </dependency>
-                <dependency>
-                    <groupId>org.apache.hop</groupId>
-                    <artifactId>hop-misc-import</artifactId>
-                    <version>${project.version}</version>
-                    <type>zip</type>
-                </dependency>
                 <dependency>
                     <groupId>org.apache.hop</groupId>
                     <artifactId>hop-misc-py4j</artifactId>
diff --git a/assemblies/web/pom.xml b/assemblies/web/pom.xml
index 94e69b8e02..afccf3c01d 100644
--- a/assemblies/web/pom.xml
+++ b/assemblies/web/pom.xml
@@ -29,10 +29,29 @@
     <name>Hop Assemblies Web</name>
 
     <properties>
-        <rap.version>4.4.0</rap.version>
+        <commons.fileupload2.version>2.0.0-M5</commons.fileupload2.version>
+        <commons.io.version>2.22.0</commons.io.version>
+        <rap.version>4.7.0</rap.version>
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>${commons.io.version}</version>
+        </dependency>
+        <!-- RAP file upload uses Commons FileUpload 2 at runtime. The web assembly
+             intentionally excludes transitive dependencies, so declare them here. -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-fileupload2-core</artifactId>
+            <version>${commons.fileupload2.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
+            <version>${commons.fileupload2.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-assemblies-static</artifactId>
diff --git a/assemblies/web/src/assembly/assembly.xml b/assemblies/web/src/assembly/assembly.xml
index b28d1e24f5..0894a7f1e3 100644
--- a/assemblies/web/src/assembly/assembly.xml
+++ b/assemblies/web/src/assembly/assembly.xml
@@ -75,6 +75,17 @@
             <useTransitiveDependencies>false</useTransitiveDependencies>
             <useProjectArtifact>false</useProjectArtifact>
         </dependencySet>
+        <!-- Commons FileUpload is a runtime dependency of RAP's file upload service. -->
+        <dependencySet>
+            <includes>
+                <include>org.apache.commons:commons-fileupload2-core</include>
+                <include>org.apache.commons:commons-fileupload2-jakarta-servlet6</include>
+                <include>commons-io:commons-io</include>
+            </includes>
+            <outputDirectory>WEB-INF/lib</outputDirectory>
+            <useTransitiveDependencies>false</useTransitiveDependencies>
+            <useProjectArtifact>false</useProjectArtifact>
+        </dependencySet>
         <dependencySet>
             <includes>
                 <include>org.apache.hop:hop-ui-rap:jar</include>
diff --git a/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java b/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
index 79d5a59cde..c287d4e98a 100644
--- a/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
@@ -29,6 +29,7 @@ import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.variables.Variables;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 /** Unit test for {@link HopVfs} */
 class HopVfsTest {
@@ -213,4 +214,29 @@ class HopVfsTest {
         HopVfs.getFileObject(userHome + "/test-file-hop.txt").getName().getURI(),
         childObj.getName().getURI());
   }
+
+  @Test
+  void testGetFileObjectForNonExistingLocalFileUri(@TempDir Path tempDir) throws Exception {
+    Path configFile = tempDir.resolve("folder with spaces #1").resolve("hop-config.json.new");
+
+    // Literal local filenames must be escaped by the caller. Path.toUri also produces the absolute
+    // file:///C:/... form required by VFS on Windows, without a process-wide path rewrite.
+    try (FileObject fileObject = HopVfs.getFileObject(configFile.toUri().toString())) {
+      assertEquals(configFile.toAbsolutePath().toString(), HopVfs.getFilename(fileObject));
+    }
+  }
+
+  @Test
+  void testSchemeLessPathRetainsPercentEncodedTraversalSemantics(@TempDir Path tempDir)
+      throws Exception {
+    Path candidate = tempDir.resolve("%2e%2e%2fsecret");
+    Path expected = tempDir.getParent().resolve("secret");
+
+    // Explorer's containment check depends on VFS decoding escapes before normalizing the path.
+    // Encoding every scheme-less path as a URI would make this look like a direct child instead.
+    try (FileObject candidateObject = HopVfs.getFileObject(candidate.toString());
+        FileObject expectedObject = HopVfs.getFileObject(expected.toString())) {
+      assertEquals(expectedObject.getName(), candidateObject.getName());
+    }
+  }
 }
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
index b103bb6459..5ded8b34d6 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
@@ -50,6 +50,31 @@ You'll feel right at home!
 
 image:hop-web.png[Hop Web,width="90%"]
 
+== Working with server files and browser files
+
+The *File* menu opens and saves files on the Hop server or its configured virtual file systems.
+The toolbar Save button and keyboard Save shortcut use this same server-side behavior.
+*Close* and *Close All* are available in this menu.
+
+The *File Browser* menu transfers files between Hop Web and your computer:
+
+* *Open* uploads a `.hpl` pipeline or `.hwf` workflow and opens an unsaved copy.
+  Use *File → Save* to choose a permanent server location for that copy.
+  Uploaded temporary paths are not added to recent files or restored when the session ends.
+* *Save* downloads the active pipeline or workflow using its suggested filename, even when it has no changes.
+* *Save As* asks for a download filename first. The browser controls where the download is stored on your computer.
+* *Export project ZIP* downloads the active project.
+* *Import Kettle/PDI ZIP* uploads a ZIP and opens the existing Kettle/PDI import dialog.
+  The extracted folder is temporary and is not stored as the last import source.
+  The standard distribution includes the import plugin; custom distributions must include `hop-misc-import`.
+
+*Export SVG* appears in both menus only while a pipeline or workflow is active.
+In *File Browser*, it downloads the SVG to your computer.
+
+A completed browser download clears the unsaved indicator of an untitled document only if its content has not changed during the transfer.
+Downloading a copy of an existing server file leaves any pending server changes marked as unsaved.
+Save or download your work before logging off or allowing the web session to expire.
+
 == Starting Hop Web with a project and environment
 
 Hop Web accepts the same variables as the default Apache Hop container image to allow Hop Web users to specify their project and environment when starting a Hop Web container:
diff --git a/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java b/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java
index 3538c44e1a..206574b95a 100644
--- a/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java
+++ b/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java
@@ -17,18 +17,25 @@
 
 package org.apache.hop.imports.gui;
 
+import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
 import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.imports.kettle.KettleImport;
 import org.apache.hop.imports.kettle.KettleImportDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.security.HopSecurityUi;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.util.EnvironmentUtils;
 
 @GuiPlugin(name = "Import", description = "Import Kettle projects")
 public class HopImportGuiPlugin {
 
+  private static final Class<?> PKG = HopImportGuiPlugin.class;
+
   public static final String ID_MAIN_MENU_FILE_IMPORT = "10060-menu-tools-import";
 
   public static HopImportGuiPlugin instance;
@@ -44,23 +51,39 @@ public class HopImportGuiPlugin {
       root = HopGui.ID_MAIN_MENU,
       id = ID_MAIN_MENU_FILE_IMPORT,
       label = "i18n::HopGuiImport.Menu.Item",
-      image = "kettle-logo.svg",
+      image = "ui/images/kettle-logo.svg",
       parentId = HopGui.ID_MAIN_MENU_FILE,
       separator = true)
   @GuiKeyboardShortcut(control = true, key = 'i', global = true)
   @GuiOsxKeyboardShortcut(command = true, key = 'i', global = true)
   public void menuToolsImport() {
+    menuToolsImport(null);
+  }
+
+  /** Opens the Kettle/PDI import dialog with an optional source folder selected up front. */
+  public void menuToolsImport(String sourceFolder) {
+    if (!HopSecurityUi.check(Permission.FILE_CREATE)
+        || !HopSecurityUi.check(Permission.METADATA_WRITE)) {
+      return;
+    }
     HopGui hopGui = HopGui.getInstance();
     try {
       // Import using this Kettle import plugin...
       //
       KettleImport kettleImport = new KettleImport();
       kettleImport.init(hopGui.getVariables(), hopGui.getLog());
+      kettleImport.setInputFolderName(sourceFolder);
       KettleImportDialog dialog =
           new KettleImportDialog(hopGui.getShell(), hopGui.getVariables(), kettleImport);
       dialog.open();
     } catch (Exception e) {
-      new ErrorDialog(hopGui.getShell(), "Error", "Error importing from Kettle", e);
+      String title = BaseMessages.getString(PKG, "HopGuiImport.Error.Title");
+      boolean web = EnvironmentUtils.getInstance().isWeb();
+      String message =
+          BaseMessages.getString(
+              PKG, web ? "HopGuiImport.Error.Web.Message" : "HopGuiImport.Error.Message");
+      hopGui.getLog().logError(message, e);
+      new ErrorDialog(hopGui.getShell(), title, message, web ? new HopException(message) : e);
     }
   }
 }
diff --git a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
index df16d3df9c..bfea79558d 100644
--- a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
+++ b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
@@ -19,6 +19,7 @@ package org.apache.hop.imports.kettle;
 
 import java.lang.reflect.InvocationTargetException;
 import java.util.List;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.extension.ExtensionPointHandler;
@@ -42,6 +43,7 @@ import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.apache.hop.ui.util.EnvironmentUtils;
 import org.apache.hop.workflow.config.WorkflowRunConfiguration;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.layout.FormAttachment;
@@ -79,12 +81,19 @@ public class KettleImportDialog extends Dialog {
   public static final String CONST_KETTLE_IMPORT_DIALOG_BUTTON_BROWSE =
       "KettleImportDialog.Button.Browse";
 
+  /**
+   * Directory prefix used by Hop Web File Browser uploads. Keep in sync with {@code
+   * HopWebUserFilePlugin} session temp directories.
+   */
+  static final String WEB_USER_FILE_TEMP_DIRECTORY_PREFIX = "hop-web-user-files-";
+
   private final IVariables variables;
 
   private Shell shell;
   private final PropsUi props;
 
   private final KettleImport kettleImport;
+  private final String configuredSourceFolder;
   private final List<String> projectNames;
 
   private TextVar wImportFrom;
@@ -111,6 +120,7 @@ public class KettleImportDialog extends Dialog {
 
     this.variables = variables;
     this.kettleImport = kettleImport;
+    this.configuredSourceFolder = kettleImport.getInputFolderName();
 
     try {
       projectNames =
@@ -516,9 +526,9 @@ public class KettleImportDialog extends Dialog {
     // See if we need to remember previous settings...
     //
     wImportFrom.setText(
-        Const.NVL(
-            AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER),
-            Const.NVL(kettleImport.getInputFolderName(), "")));
+        initialSourceFolder(
+            kettleImport.getInputFolderName(),
+            AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER)));
     wImportInExisting.setSelection(
         !CONST_FALSE.equalsIgnoreCase(
             AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_INTO_PROJECT)));
@@ -561,7 +571,10 @@ public class KettleImportDialog extends Dialog {
 
   public void dispose() {
     props.setScreen(new WindowProperty(shell));
-    AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER, wImportFrom.getText());
+    String sourceFolder = wImportFrom.getText();
+    if (shouldRememberSourceFolder(configuredSourceFolder, sourceFolder)) {
+      AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER, sourceFolder);
+    }
     AuditManagerGuiUtil.addLastUsedValue(
         LAST_USED_IMPORT_INTO_PROJECT, wImportInExisting.getSelection() ? "true" : CONST_FALSE);
     AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_TARGET_PROJECT, wImportProject.getText());
@@ -583,6 +596,48 @@ public class KettleImportDialog extends Dialog {
     shell.dispose();
   }
 
+  static String initialSourceFolder(String configuredSourceFolder, String lastUsedSourceFolder) {
+    if (StringUtils.isNotBlank(configuredSourceFolder)) {
+      return configuredSourceFolder;
+    }
+    if (StringUtils.isBlank(lastUsedSourceFolder)
+        || isEphemeralWebUploadFolder(lastUsedSourceFolder)) {
+      return "";
+    }
+    return lastUsedSourceFolder;
+  }
+
+  /**
+   * File Browser ZIP uploads extract into a session temp folder that is deleted when the dialog
+   * closes. Do not persist that path, and ignore it if it was already stored as last-used.
+   */
+  static boolean shouldRememberSourceFolder(
+      String configuredSourceFolder, String currentSourceFolder) {
+    if (isEphemeralWebUploadFolder(currentSourceFolder)) {
+      return false;
+    }
+    if (StringUtils.isBlank(configuredSourceFolder)) {
+      return true;
+    }
+    return StringUtils.isNotBlank(currentSourceFolder)
+        && !sameSourceFolder(configuredSourceFolder, currentSourceFolder);
+  }
+
+  static boolean isEphemeralWebUploadFolder(String folder) {
+    if (StringUtils.isBlank(folder)) {
+      return false;
+    }
+    String normalized = folder.replace('\\', '/');
+    return normalized.contains("/" + WEB_USER_FILE_TEMP_DIRECTORY_PREFIX)
+        || normalized.startsWith(WEB_USER_FILE_TEMP_DIRECTORY_PREFIX);
+  }
+
+  private static boolean sameSourceFolder(String left, String right) {
+    return StringUtils.equals(
+        StringUtils.removeEnd(left.replace('\\', '/'), "/"),
+        StringUtils.removeEnd(right.replace('\\', '/'), "/"));
+  }
+
   private void browseHomeFolder(Event event) {
     BaseDialog.presentDirectoryDialog(shell, wImportFrom, variables);
   }
@@ -725,7 +780,14 @@ public class KettleImportDialog extends Dialog {
         box.open();
       }
     } catch (Exception e) {
-      new ErrorDialog(shell, "Error", "Error importing", e);
+      String title = BaseMessages.getString(PKG, "KettleImportDialog.Error.Title");
+      boolean web = EnvironmentUtils.getInstance().isWeb();
+      String message =
+          BaseMessages.getString(
+              PKG,
+              web ? "KettleImportDialog.Error.Web.Message" : "KettleImportDialog.Error.Message");
+      LogChannel.UI.logError(message, e);
+      new ErrorDialog(shell, title, message, web ? new HopException(message) : e);
     }
   }
 
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties
index cc1dd6f8c3..e13a374e39 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties
@@ -16,3 +16,6 @@
 #
 
 HopGuiImport.Menu.Item=Import from Kettle/PDI
+HopGuiImport.Error.Title=Kettle/PDI import
+HopGuiImport.Error.Message=Unable to open the Kettle/PDI import.
+HopGuiImport.Error.Web.Message=Unable to open the Kettle/PDI import. See the server log for details.
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties
index 1692cdb472..7f5ee60f93 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties
@@ -1,5 +1,4 @@
 #
-#
 # 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.
@@ -17,3 +16,8 @@
 #
 #
 
+HopGuiImport.Menu.Item=Importar desde Kettle/PDI
+HopGuiImport.Error.Title=Importación de Kettle/PDI
+HopGuiImport.Error.Message=No se pudo abrir la importación de Kettle/PDI.
+HopGuiImport.Error.Web.Message=No se pudo abrir la importación de Kettle/PDI. Consulte el registro del servidor para obtener más detalles.
+
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
index 896994a49a..de7f71ad2d 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
@@ -40,3 +40,6 @@ KettleImportDialog.Pipeline.RunConfiguration.Label=Pipeline default run configur
 KettleImportDialog.RunConfiguration.Tooltip=Sets the default run configuration in case it was not specified
 KettleImportDialog.Shell.Name=Import code to Hop
 KettleImportDialog.Workflow.RunConfiguration.Label=Workflow default run configuration
+KettleImportDialog.Error.Title=Kettle/PDI import
+KettleImportDialog.Error.Message=The Kettle/PDI import failed.
+KettleImportDialog.Error.Web.Message=The Kettle/PDI import failed. See the server log for details.
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties
index 1692cdb472..1d1ebfd562 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties
@@ -1,5 +1,4 @@
 #
-#
 # 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.
@@ -17,3 +16,7 @@
 #
 #
 
+KettleImportDialog.Error.Title=Importación de Kettle/PDI
+KettleImportDialog.Error.Message=La importación de Kettle/PDI falló.
+KettleImportDialog.Error.Web.Message=La importación de Kettle/PDI falló. Consulte el registro del servidor para obtener más detalles.
+
diff --git a/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogTest.java b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogTest.java
new file mode 100644
index 0000000000..e989c84117
--- /dev/null
+++ b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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.imports.kettle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.imports.gui.HopImportGuiPlugin;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.widgets.Button;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+class KettleImportDialogTest {
+
+  @BeforeAll
+  static void initializeLogging() {
+    HopLogStore.init();
+  }
+
+  @Test
+  void browserImportEntryPointIsPubliclyAvailable() throws Exception {
+    assertNotNull(HopImportGuiPlugin.class.getMethod("menuToolsImport", String.class));
+  }
+
+  @Test
+  void configuredBrowserUploadFolderOverridesLastUsedFolder() {
+    assertEquals(
+        "uploaded-project",
+        KettleImportDialog.initialSourceFolder("uploaded-project", "previous-project"));
+  }
+
+  @Test
+  void fallsBackToLastUsedFolderForTheTraditionalMenu() {
+    assertEquals(
+        "previous-project", KettleImportDialog.initialSourceFolder(null, "previous-project"));
+    assertEquals("", KettleImportDialog.initialSourceFolder(null, null));
+  }
+
+  @Test
+  void ignoresADeletedFileBrowserUploadAsLastUsedSource() {
+    assertEquals(
+        "",
+        KettleImportDialog.initialSourceFolder(
+            null, "/tmp/hop-web-user-files-abc/kettle-import-1"));
+    assertEquals(
+        "",
+        KettleImportDialog.initialSourceFolder(
+            null, "C:\\Temp\\hop-web-user-files-abc\\kettle-import-1"));
+    assertEquals(
+        "uploaded-project",
+        KettleImportDialog.initialSourceFolder(
+            "uploaded-project", "/tmp/hop-web-user-files-abc/kettle-import-1"));
+  }
+
+  @Test
+  void remembersTraditionalSourceFoldersButNotFileBrowserUploads() {
+    assertTrue(KettleImportDialog.shouldRememberSourceFolder(null, "/home/kettle"));
+    assertTrue(KettleImportDialog.shouldRememberSourceFolder("", "/home/kettle"));
+    assertFalse(
+        KettleImportDialog.shouldRememberSourceFolder(
+            "/tmp/hop-web-user-files-abc/kettle-import-1",
+            "/tmp/hop-web-user-files-abc/kettle-import-1"));
+    assertFalse(
+        KettleImportDialog.shouldRememberSourceFolder(
+            null, "/tmp/hop-web-user-files-abc/kettle-import-1"));
+    assertTrue(
+        KettleImportDialog.shouldRememberSourceFolder(
+            "/tmp/hop-web-user-files-abc/kettle-import-1", "/srv/kettle"));
+    assertFalse(
+        KettleImportDialog.shouldRememberSourceFolder(
+            "/tmp/hop-web-user-files-abc/kettle-import-1/",
+            "/tmp/hop-web-user-files-abc/kettle-import-1"));
+  }
+
+  @Test
+  void detectsHopWebSessionTempUploadFolders() {
+    assertTrue(
+        KettleImportDialog.isEphemeralWebUploadFolder(
+            "/tmp/hop-web-user-files-xyz/kettle-import-1"));
+    assertTrue(
+        KettleImportDialog.isEphemeralWebUploadFolder(
+            "C:\\Users\\hop\\AppData\\Local\\Temp\\hop-web-user-files-xyz\\project"));
+    assertFalse(KettleImportDialog.isEphemeralWebUploadFolder("/home/kettle/project"));
+    assertFalse(KettleImportDialog.isEphemeralWebUploadFolder(null));
+    assertFalse(KettleImportDialog.isEphemeralWebUploadFolder(""));
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void openingImportPreservesDesktopDetailsAndHidesWebServerPaths(boolean web) {
+    HopGui hopGui = mock(HopGui.class);
+    ILogChannel log = mock(ILogChannel.class);
+    EnvironmentUtils environment = mock(EnvironmentUtils.class);
+    RuntimeException failure = importFailure();
+    when(hopGui.getVariables()).thenThrow(failure);
+    when(hopGui.getLog()).thenReturn(log);
+    when(environment.isWeb()).thenReturn(web);
+    AtomicReference<List<?>> dialogArguments = new AtomicReference<>();
+
+    try (MockedStatic<HopGui> hopGuiStatic = mockStatic(HopGui.class);
+        MockedStatic<HopSecurityUi> security = mockStatic(HopSecurityUi.class);
+        MockedStatic<EnvironmentUtils> environmentStatic = mockStatic(EnvironmentUtils.class);
+        MockedConstruction<ErrorDialog> dialogs =
+            mockConstruction(
+                ErrorDialog.class, (dialog, context) -> dialogArguments.set(context.arguments()))) {
+      hopGuiStatic.when(HopGui::getInstance).thenReturn(hopGui);
+      security.when(() -> HopSecurityUi.check(Permission.FILE_CREATE)).thenReturn(true);
+      security.when(() -> HopSecurityUi.check(Permission.METADATA_WRITE)).thenReturn(true);
+      environmentStatic.when(EnvironmentUtils::getInstance).thenReturn(environment);
+
+      new HopImportGuiPlugin().menuToolsImport("uploaded-project");
+
+      assertEquals(1, dialogs.constructed().size());
+      assertImportError(
+          web, failure, dialogArguments.get(), HopImportGuiPlugin.class, "HopGuiImport");
+      verify(log).logError(anyString(), same(failure));
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void importingPreservesDesktopDetailsAndHidesWebServerPaths(boolean web) throws Exception {
+    // Exercise the import failure path without opening an SWT display or native dialog.
+    KettleImportDialog importDialog = mock(KettleImportDialog.class);
+    Button importInExisting = mock(Button.class);
+    EnvironmentUtils environment = mock(EnvironmentUtils.class);
+    RuntimeException failure = importFailure();
+    when(importInExisting.getSelection()).thenThrow(failure);
+    when(environment.isWeb()).thenReturn(web);
+    Field importInExistingField = KettleImportDialog.class.getDeclaredField("wImportInExisting");
+    importInExistingField.setAccessible(true);
+    importInExistingField.set(importDialog, importInExisting);
+    Method doImport = KettleImportDialog.class.getDeclaredMethod("doImport");
+    doImport.setAccessible(true);
+    AtomicReference<List<?>> dialogArguments = new AtomicReference<>();
+
+    try (MockedStatic<EnvironmentUtils> environmentStatic = mockStatic(EnvironmentUtils.class);
+        MockedConstruction<ErrorDialog> dialogs =
+            mockConstruction(
+                ErrorDialog.class, (dialog, context) -> dialogArguments.set(context.arguments()))) {
+      environmentStatic.when(EnvironmentUtils::getInstance).thenReturn(environment);
+
+      doImport.invoke(importDialog);
+
+      assertEquals(1, dialogs.constructed().size());
+      assertImportError(
+          web, failure, dialogArguments.get(), KettleImportDialog.class, "KettleImportDialog");
+    }
+  }
+
+  private static RuntimeException importFailure() {
+    return new IllegalStateException(
+        "Unable to read /srv/private/import/kettle.properties",
+        new IOException("/srv/private/config/passwords.properties"));
+  }
+
+  private static void assertImportError(
+      boolean web, Exception failure, List<?> arguments, Class<?> pkg, String messagePrefix) {
+    String message = (String) arguments.get(2);
+    Exception displayedFailure = (Exception) arguments.get(3);
+    assertFalse(message.contains("/srv/private"));
+    if (web) {
+      assertEquals(BaseMessages.getString(pkg, messagePrefix + ".Error.Web.Message"), message);
+      assertEquals(message, displayedFailure.getMessage().trim());
+      assertNull(displayedFailure.getCause());
+      assertEquals(0, displayedFailure.getSuppressed().length);
+    } else {
+      assertEquals(BaseMessages.getString(pkg, messagePrefix + ".Error.Message"), message);
+      assertFalse(message.contains("server log"));
+      assertSame(failure, displayedFailure);
+      assertSame(failure.getCause(), displayedFailure.getCause());
+    }
+  }
+}
diff --git a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
index 9cb5591f8f..2c3e08b99d 100644
--- a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
+++ b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
@@ -35,6 +35,9 @@ import org.apache.hop.core.exception.HopException;
  */
 public final class HopHome {
 
+  private static final String HOP_CONFIG_FOLDER = "HOP_CONFIG_FOLDER";
+  private static final String HOP_PLUGIN_BASE_FOLDERS = "HOP_PLUGIN_BASE_FOLDERS";
+
   private HopHome() {}
 
   public static Path resolve() throws HopException {
@@ -61,9 +64,38 @@ public final class HopHome {
     if (parent != null) {
       paths.add(parent);
     }
+
+    // Web deployments can keep the writable plugins and configuration outside the binaries.
+    // Resolve those configured locations without changing the launcher behavior above.
+    addConfiguredParent(paths, System.getProperty(HOP_CONFIG_FOLDER), "config");
+    addConfiguredParent(paths, System.getenv(HOP_CONFIG_FOLDER), "config");
+    addConfiguredPluginParents(paths, System.getProperty(HOP_PLUGIN_BASE_FOLDERS));
+    addConfiguredPluginParents(paths, System.getenv(HOP_PLUGIN_BASE_FOLDERS));
     return paths;
   }
 
+  private static void addConfiguredPluginParents(Set<Path> paths, String configured) {
+    if (configured == null || configured.isBlank()) {
+      return;
+    }
+    for (String folder : configured.split(",")) {
+      addConfiguredParent(paths, folder, "plugins");
+    }
+  }
+
+  private static void addConfiguredParent(Set<Path> paths, String configured, String childName) {
+    if (configured == null || configured.isBlank()) {
+      return;
+    }
+    Path path = Paths.get(configured.trim()).toAbsolutePath().normalize();
+    if (path.getFileName() != null && childName.equalsIgnoreCase(path.getFileName().toString())) {
+      path = path.getParent();
+    }
+    if (path != null) {
+      paths.add(path);
+    }
+  }
+
   /** True if path is a directory that looks like a Hop install (has {@code plugins/}). */
   static boolean isHopHome(Path path) {
     return path != null && Files.isDirectory(path) && Files.isDirectory(path.resolve("plugins"));
diff --git a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
index 1c25abf046..a620eb5219 100644
--- a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
+++ b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
@@ -238,12 +238,6 @@ plugins:
     description: Generate Hop documentation
     installPath: plugins/misc/documentation
     modulePath: plugins/misc/documentation
-  - artifactId: hop-misc-import
-    name: Import
-    category: Miscellaneous
-    description: Import Kettle / Pentaho Data Integration metadata
-    installPath: plugins/misc/import
-    modulePath: plugins/misc/import
   - artifactId: hop-misc-lint
     name: Lint
     category: Miscellaneous
diff --git a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
index fae4b4a184..4caf4c9ee9 100644
--- a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
+++ b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
@@ -66,4 +66,45 @@ class HopHomeTest {
       System.setProperty("user.dir", previous);
     }
   }
+
+  @Test
+  void resolveUsesConfiguredPluginFolderWhenUserDirIsNotHopHome() throws Exception {
+    Path hop = tempDir.resolve("hop-data");
+    Files.createDirectories(hop.resolve("plugins"));
+    String previousUserDir = System.getProperty("user.dir");
+    String previousPluginFolders = System.getProperty("HOP_PLUGIN_BASE_FOLDERS");
+    try {
+      System.setProperty("user.dir", tempDir.resolve("not-hop").toString());
+      System.setProperty("HOP_PLUGIN_BASE_FOLDERS", hop.resolve("plugins").toString());
+      assertEquals(hop.toAbsolutePath().normalize(), HopHome.resolve());
+    } finally {
+      restoreProperty("user.dir", previousUserDir);
+      restoreProperty("HOP_PLUGIN_BASE_FOLDERS", previousPluginFolders);
+    }
+  }
+
+  @Test
+  void resolveUsesParentOfConfiguredConfigFolder() throws Exception {
+    Path hop = tempDir.resolve("hop-web");
+    Files.createDirectories(hop.resolve("plugins"));
+    Files.createDirectories(hop.resolve("config"));
+    String previousUserDir = System.getProperty("user.dir");
+    String previousConfigFolder = System.getProperty("HOP_CONFIG_FOLDER");
+    try {
+      System.setProperty("user.dir", tempDir.resolve("not-hop").toString());
+      System.setProperty("HOP_CONFIG_FOLDER", hop.resolve("config").toString());
+      assertEquals(hop.toAbsolutePath().normalize(), HopHome.resolve());
+    } finally {
+      restoreProperty("user.dir", previousUserDir);
+      restoreProperty("HOP_CONFIG_FOLDER", previousConfigFolder);
+    }
+  }
+
+  private static void restoreProperty(String name, String value) {
+    if (value == null) {
+      System.clearProperty(name);
+    } else {
+      System.setProperty(name, value);
+    }
+  }
 }
diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 77ecf6d69a..da331aa884 100644
--- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -1706,6 +1706,14 @@ public class ProjectsGuiPlugin {
     if (zipFilename == null) {
       return;
     }
+    exportProject(zipFilename, true);
+  }
+
+  public void exportProject(String zipFilename, boolean showConfirmation) {
+    HopGui hopGui = HopGui.getInstance();
+    Shell shell = hopGui.getShell();
+    IVariables variables = hopGui.getVariables();
+
     String projectName =
         HopGui.getInstance().getStatusToolbarWidgets().getToolbarItemText(ID_TOOLBAR_ITEM_PROJECT);
     if (StringUtils.isEmpty(projectName)) {
@@ -1713,7 +1721,7 @@ public class ProjectsGuiPlugin {
     }
     ProjectsConfig config = ProjectsConfigSingleton.getConfig();
     ProjectConfig projectConfig = config.findProjectConfig(projectName);
-    String projectHome = projectConfig.getProjectHome();
+    String projectHome = variables.resolve(projectConfig.getProjectHome());
 
     AtomicBoolean includeVariables = new AtomicBoolean(true);
     AtomicBoolean includeMetadata = new AtomicBoolean(true);
@@ -1856,94 +1864,95 @@ public class ProjectsGuiPlugin {
               boolean asIs = exportAsIs.get();
 
               FileObject zipFile = HopVfs.getFileObject(zipFilename);
-              OutputStream outputStream = HopVfs.getOutputStream(zipFile, false);
-              ZipOutputStream zos = new ZipOutputStream(outputStream);
-              FileObject projectDirectory = HopVfs.getFileObject(projectHome);
-              // As-is: strip project home so entries land at the zip root (compatible with
-              // zip:file://…!/ read-only project open). Normal: strip parent so entries are under
-              // {projectName}/…
-              String stripBase =
-                  asIs
-                      ? projectDirectory.getName().getURI()
-                      : projectDirectory.getParent().getName().getURI();
-
-              // Includes selected files and dependencies
-              if (!tree.getFileObjects().isEmpty()) {
-                for (FileObject fileObject : tree.getFileObjects()) {
-                  // To prevent the zip file from including itself
-                  if (zipFile.equals(fileObject)) {
-                    continue;
+              try (OutputStream outputStream = HopVfs.getOutputStream(zipFile, false);
+                  ZipOutputStream zos = new ZipOutputStream(outputStream)) {
+                FileObject projectDirectory = HopVfs.getFileObject(projectHome);
+                // As-is: strip project home so entries land at the zip root (compatible with
+                // zip:file://…!/ read-only project open). Normal: strip parent so entries are under
+                // {projectName}/…
+                String stripBase =
+                    asIs
+                        ? projectDirectory.getName().getURI()
+                        : projectDirectory.getParent().getName().getURI();
+
+                // Includes selected files and dependencies
+                if (!tree.getFileObjects().isEmpty()) {
+                  for (FileObject fileObject : tree.getFileObjects()) {
+                    // To prevent the zip file from including itself
+                    if (zipFile.equals(fileObject)) {
+                      continue;
+                    }
+                    monitor.subTask(fileObject.getName().getURI());
+                    zipFile(fileObject, fileObject.getName().getURI(), zos, stripBase);
                   }
-                  monitor.subTask(fileObject.getName().getURI());
-                  zipFile(fileObject, fileObject.getName().getURI(), zos, stripBase);
                 }
-              }
 
-              // Sidecars only for the classic (non as-is) export format
-              if (!asIs) {
-                HashMap<String, String> variablesMap = new HashMap<>();
-
-                for (String name : variables.getVariableNames()) {
-                  if (!name.contains("java.")
-                      && !name.contains("user.")
-                      && !name.contains("sun.")
-                      && !name.contains("os.")
-                      && !name.contains("file.")
-                      && !name.contains("jdk.")
-                      && !name.contains("http.")
-                      && !name.contains("path.")
-                      && !name.contains("ftp.")
-                      && !name.contains("line.")
-                      && !name.contains("awt.")
-                      && !name.equals("HOP_METADATA_FOLDER")
-                      && !name.contains("HOP_ENVIRONMENT_NAME")
-                      && !name.contains("HOP_AUDIT_FOLDER")
-                      && !name.contains("HOP_CONFIG_FOLDER")
-                      && !name.contains("PROJECT_HOME")
-                      && !name.contains("PARENT_PROJECT_NAME")
-                      && !name.contains("HOP_PROJECTS")
-                      && !name.contains("HOP_PLATFORM_OS")
-                      && !name.contains("HOP_PROJECT_NAME")
-                      && !name.contains("HOP_SERVER_URL")) {
-                    String value = variables.getVariable(name);
-                    variablesMap.put(name, value);
+                // Sidecars only for the classic (non as-is) export format
+                if (!asIs) {
+                  HashMap<String, String> variablesMap = new HashMap<>();
+
+                  for (String name : variables.getVariableNames()) {
+                    if (!name.contains("java.")
+                        && !name.contains("user.")
+                        && !name.contains("sun.")
+                        && !name.contains("os.")
+                        && !name.contains("file.")
+                        && !name.contains("jdk.")
+                        && !name.contains("http.")
+                        && !name.contains("path.")
+                        && !name.contains("ftp.")
+                        && !name.contains("line.")
+                        && !name.contains("awt.")
+                        && !name.equals("HOP_METADATA_FOLDER")
+                        && !name.contains("HOP_ENVIRONMENT_NAME")
+                        && !name.contains("HOP_AUDIT_FOLDER")
+                        && !name.contains("HOP_CONFIG_FOLDER")
+                        && !name.contains("PROJECT_HOME")
+                        && !name.contains("PARENT_PROJECT_NAME")
+                        && !name.contains("HOP_PROJECTS")
+                        && !name.contains("HOP_PLATFORM_OS")
+                        && !name.contains("HOP_PROJECT_NAME")
+                        && !name.contains("HOP_SERVER_URL")) {
+                      String value = variables.getVariable(name);
+                      variablesMap.put(name, value);
+                    }
                   }
-                }
-                ObjectMapper objectMapper = new ObjectMapper();
-                String variablesJson = objectMapper.writeValueAsString(variablesMap);
-
-                SerializableMetadataProvider metadataProvider =
-                    new SerializableMetadataProvider(hopGui.getMetadataProvider());
-                String metadataJson = metadataProvider.toJson();
-
-                // Includes config file
-                zipFile(
-                    HopVfs.getFileObject(
-                        Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR + Const.HOP_CONFIG),
-                    projectDirectory.getName().getBaseName()
-                        + Const.FILE_SEPARATOR
-                        + Const.HOP_CONFIG,
-                    zos,
-                    stripBase);
-
-                // Includes variables
-                if (includeVariables.get()) {
-                  zipString(
-                      variablesJson,
-                      "variables.json",
+                  ObjectMapper objectMapper = new ObjectMapper();
+                  String variablesJson = objectMapper.writeValueAsString(variablesMap);
+
+                  SerializableMetadataProvider metadataProvider =
+                      new SerializableMetadataProvider(hopGui.getMetadataProvider());
+                  String metadataJson = metadataProvider.toJson();
+
+                  // Includes config file
+                  zipFile(
+                      HopVfs.getFileObject(
+                          Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR + Const.HOP_CONFIG),
+                      projectDirectory.getName().getBaseName()
+                          + Const.FILE_SEPARATOR
+                          + Const.HOP_CONFIG,
                       zos,
-                      projectDirectory.getName().getBaseName());
-                }
+                      stripBase);
+
+                  // Includes variables
+                  if (includeVariables.get()) {
+                    zipString(
+                        variablesJson,
+                        "variables.json",
+                        zos,
+                        projectDirectory.getName().getBaseName());
+                  }
 
-                // Includes metadata
-                if (includeMetadata.get()) {
-                  zipString(
-                      metadataJson, "metadata.json", zos, projectDirectory.getName().getBaseName());
+                  // Includes metadata
+                  if (includeMetadata.get()) {
+                    zipString(
+                        metadataJson,
+                        "metadata.json",
+                        zos,
+                        projectDirectory.getName().getBaseName());
+                  }
                 }
               }
-
-              zos.close();
-              outputStream.close();
             } catch (Exception e) {
               throw new InvocationTargetException(e, "Error zipping project: " + e.getMessage());
             } finally {
@@ -1954,21 +1963,27 @@ public class ProjectsGuiPlugin {
       ProgressMonitorDialog pmd = new ProgressMonitorDialog(shell);
       pmd.run(false, op);
 
-      GuiResource.getInstance().toClipboard(zipFilename);
+      if (showConfirmation) {
+        GuiResource.getInstance().toClipboard(zipFilename);
 
-      MessageBox box = new MessageBox(shell, SWT.CLOSE | SWT.ICON_INFORMATION);
-      box.setText(BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Header"));
-      box.setMessage(
-          BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message1", zipFilename)
-              + Const.CR
-              + BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message2"));
-      box.open();
+        MessageBox box = new MessageBox(shell, SWT.CLOSE | SWT.ICON_INFORMATION);
+        box.setText(BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Header"));
+        box.setMessage(
+            BaseMessages.getString(
+                    PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message1", zipFilename)
+                + Const.CR
+                + BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message2"));
+        box.open();
+      }
     } catch (Exception e) {
+      String errorMessage =
+          BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message");
+      hopGui.getLog().logError(errorMessage, e);
       new ErrorDialog(
           HopGui.getInstance().getShell(),
           BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Error.Dialog.Header"),
-          BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message"),
-          e);
+          errorMessage,
+          showConfirmation ? e : new HopException(errorMessage));
     }
   }
 
diff --git a/rap/pom.xml b/rap/pom.xml
index 2bf92e85cf..050384d094 100644
--- a/rap/pom.xml
+++ b/rap/pom.xml
@@ -35,6 +35,11 @@
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>org.eclipse.rap</groupId>
+            <artifactId>org.eclipse.rap.filedialog</artifactId>
+            <version>${rap.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.eclipse.rap</groupId>
             <artifactId>org.eclipse.rap.rwt</artifactId>
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
new file mode 100644
index 0000000000..877543bc1d
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
@@ -0,0 +1,666 @@
+/*
+ * 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;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.base.AbstractMeta;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuItem;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.PipelineSvgPainter;
+import org.apache.hop.ui.core.dialog.EnterStringDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.WorkflowSvgPainter;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.service.UISession;
+import org.w3c.dom.Node;
+
+/** Hop Web menu actions for opening and saving files on the user's computer. */
+@GuiPlugin(name = "Hop Web user files")
+public class HopWebUserFilePlugin {
+
+  private static final Class<?> PKG = HopWebUserFilePlugin.class;
+
+  public static final String ID_MAIN_MENU_FILE_USER = "15000-menu-file-user";
+
+  private static final String PROJECT_EXPORT_MENU_ID = "10055-menu-file-export-to-svg";
+  private static final String KETTLE_IMPORT_MENU_ID = "10060-menu-tools-import";
+  private static final String SESSION_TEMP_DIRECTORY =
+      HopWebUserFilePlugin.class.getName() + ".tempDirectory";
+  static final String SESSION_TEMP_DIRECTORY_PREFIX = "hop-web-user-files-";
+  private static final String HOP_FILE_EXTENSIONS = ".hpl,.hwf";
+  private static final long MAX_HOP_FILE_SIZE = 25L * 1024 * 1024;
+  private static final long MAX_COMPRESSED_ZIP_SIZE = 100L * 1024 * 1024;
+  private static final long MAX_UNCOMPRESSED_ZIP_SIZE = 250L * 1024 * 1024;
+  private static final long MAX_UNCOMPRESSED_ZIP_ENTRY_SIZE = 64L * 1024 * 1024;
+  private static final long MIN_COMPRESSION_RATIO_SIZE = 1024L * 1024;
+  private static final int MAX_COMPRESSION_RATIO = 100;
+  private static final int MAX_ZIP_ENTRIES = 10_000;
+  private static final int MAX_ZIP_ENTRY_DEPTH = 32;
+  private static final int MAX_ZIP_ENTRY_NAME_LENGTH = 4096;
+
+  private final Map<IHopFileTypeHandler, String> userFileNames = new IdentityHashMap<>();
+  private UserFileTransfer transfer;
+  private boolean sessionCleanupRegistered;
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = ID_MAIN_MENU_FILE_USER,
+      label = "i18n::HopGui.Menu.File.User",
+      parentId = HopGui.ID_MAIN_MENU)
+  public void menuFileUser() {
+    // Category only.
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_NEW,
+      label = "i18n::HopGui.Menu.File.New",
+      image = "ui/images/add.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void newFile() {
+    HopGui.getInstance().menuFileNew();
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_OPEN,
+      label = "i18n::HopGui.Menu.File.Open",
+      image = "ui/images/open.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void openFile() {
+    if (!HopSecurityUi.check(Permission.FILE_VIEW)) {
+      return;
+    }
+    try {
+      transfer()
+          .open(
+              HOP_FILE_EXTENSIONS,
+              MAX_HOP_FILE_SIZE,
+              new UserFileTransfer.UploadListener() {
+                @Override
+                public void uploaded(String filename, Path uploadedFile) {
+                  openUploadedFile(filename, uploadedFile);
+                }
+
+                @Override
+                public void error(Exception exception) {
+                  showError("HopGui.FileBrowser.Error.Upload", exception);
+                }
+              });
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.Upload", e);
+    }
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_SAVE,
+      label = "i18n::HopGui.Menu.File.Save",
+      image = "ui/images/save.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void saveFile() {
+    downloadActiveFile(false);
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_SAVE_AS,
+      label = "i18n::HopGui.Menu.File.SaveAs",
+      image = "ui/images/save-as.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void saveFileAs() {
+    downloadActiveFile(true);
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG,
+      label = "i18n::HopGui.Menu.File.ExportToSVG",
+      image = "ui/images/image.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void exportToSvg() {
+    if (!HopSecurityUi.check(Permission.FILE_EXPORT)) {
+      return;
+    }
+    try {
+      HopGuiPipelineGraph pipelineGraph = HopGui.getActivePipelineGraph();
+      if (pipelineGraph != null) {
+        String name = safeFilename(pipelineGraph.getPipelineMeta().getName(), ".svg");
+        String svg =
+            PipelineSvgPainter.generatePipelineSvg(
+                pipelineGraph.getPipelineMeta(), 1.0f, pipelineGraph.getVariables());
+        transfer().download(name, "image/svg+xml", svg.getBytes(StandardCharsets.UTF_8));
+        return;
+      }
+
+      HopGuiWorkflowGraph workflowGraph = HopGui.getActiveWorkflowGraph();
+      if (workflowGraph != null) {
+        String name = safeFilename(workflowGraph.getWorkflowMeta().getName(), ".svg");
+        String svg =
+            WorkflowSvgPainter.generateWorkflowSvg(
+                workflowGraph.getWorkflowMeta(), 1.0f, workflowGraph.getVariables());
+        transfer().download(name, "image/svg+xml", svg.getBytes(StandardCharsets.UTF_8));
+        return;
+      }
+      // The menu item is hidden until a pipeline or workflow is active. Keep this guard quiet as
+      // well in case a stale keyboard shortcut or menu event reaches the action.
+      return;
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.ExportSvg", e);
+    }
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT,
+      label = "i18n::HopGui.Menu.File.ExportProjectZip",
+      image = "ui/images/zipfile.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void exportProjectZip() {
+    if (!HopSecurityUi.check(Permission.FILE_EXPORT)) {
+      return;
+    }
+    Path zipFile = null;
+    try {
+      zipFile = Files.createTempFile(getSessionTempDirectory(), "hop-project-", ".zip");
+      Files.deleteIfExists(zipFile);
+      invokeGuiPlugin(
+          PROJECT_EXPORT_MENU_ID,
+          "exportProject",
+          new Class<?>[] {String.class, boolean.class},
+          zipFile.toString(),
+          false);
+      if (!Files.exists(zipFile)) {
+        return;
+      }
+
+      String projectName =
+          HopGui.getInstance().getVariables().getVariable("HOP_PROJECT_NAME", "hop-project");
+      transfer().download(safeFilename(projectName, ".zip"), "application/zip", zipFile);
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.ExportProject", e);
+    } finally {
+      if (zipFile != null) {
+        try {
+          Files.deleteIfExists(zipFile);
+        } catch (IOException ignored) {
+          // Best effort after the browser download has been prepared.
+        }
+      }
+    }
+  }
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = HopGui.ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE,
+      label = "i18n::HopGui.Menu.File.ImportKettleZip",
+      image = "ui/images/kettle-logo.svg",
+      parentId = ID_MAIN_MENU_FILE_USER)
+  public void importFromKettleZip() {
+    if (!HopSecurityUi.check(Permission.FILE_CREATE)
+        || !HopSecurityUi.check(Permission.METADATA_WRITE)) {
+      return;
+    }
+    try {
+      transfer()
+          .open(
+              ".zip",
+              MAX_COMPRESSED_ZIP_SIZE,
+              new UserFileTransfer.UploadListener() {
+                @Override
+                public void uploaded(String filename, Path uploadedFile) {
+                  openKettleImport(filename, uploadedFile);
+                }
+
+                @Override
+                public void error(Exception exception) {
+                  showError("HopGui.FileBrowser.Error.ImportKettle", exception);
+                }
+              });
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.ImportKettle", e);
+    }
+  }
+
+  private UserFileTransfer transfer() throws IOException {
+    if (transfer == null || transfer.isDisposed()) {
+      transfer = new UserFileTransfer(HopGui.getInstance().getShell(), getSessionTempDirectory());
+      if (!sessionCleanupRegistered) {
+        RWT.getUISession().addUISessionListener(event -> userFileNames.clear());
+        sessionCleanupRegistered = true;
+      }
+    }
+    return transfer;
+  }
+
+  private void openUploadedFile(String filename, Path uploadedFile) {
+    try {
+      if (!HopSecurityUi.check(Permission.FILE_VIEW)) {
+        return;
+      }
+      String safeName = safeFilename(filename, "");
+      HopGui hopGui = HopGui.getInstance();
+      AbstractMeta model =
+          readUploadedFile(
+              safeName, uploadedFile, hopGui.getMetadataProvider(), hopGui.getVariables());
+      // The editor must never see the upload path: tab state, recent files and file watchers
+      // otherwise retain a session temporary file as if it were a saved server document.
+      IHopFileTypeHandler handler =
+          model instanceof PipelineMeta pipeline
+              ? HopGui.getExplorerPerspective().addPipeline(pipeline)
+              : HopGui.getExplorerPerspective().addWorkflow((WorkflowMeta) model);
+      userFileNames.put(handler, safeName);
+      handler.updateGui();
+      HopGui.getExplorerPerspective().activate();
+      ExtensionPointHandler.callExtensionPoint(
+          hopGui.getLog(),
+          hopGui.getVariables(),
+          model instanceof PipelineMeta
+              ? HopExtensionPoint.PipelineAfterOpen.id
+              : HopExtensionPoint.WorkflowAfterOpen.id,
+          model);
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.Open", e);
+    }
+  }
+
+  static AbstractMeta readUploadedFile(
+      String filename,
+      Path uploadedFile,
+      IHopMetadataProvider metadataProvider,
+      IVariables variables)
+      throws IOException, HopException {
+    String extension = FilenameUtils.getExtension(filename);
+    boolean pipeline = "hpl".equalsIgnoreCase(extension);
+    if (!pipeline && !"hwf".equalsIgnoreCase(extension)) {
+      throw new HopException("Only Hop pipeline and workflow files can be opened here.");
+    }
+    try (InputStream input = Files.newInputStream(uploadedFile)) {
+      Node root = XmlHandler.loadXmlFile(input, null, false, false).getDocumentElement();
+      String expectedTag = pipeline ? PipelineMeta.XML_TAG : WorkflowMeta.XML_TAG;
+      if (root == null || !expectedTag.equals(root.getNodeName())) {
+        throw new HopException("The uploaded file does not contain the selected Hop file type.");
+      }
+      AbstractMeta model;
+      if (pipeline) {
+        PipelineMeta pipelineMeta = new PipelineMeta();
+        pipelineMeta.loadXml(root, null, metadataProvider, variables);
+        model = pipelineMeta;
+      } else {
+        model = new WorkflowMeta(root, metadataProvider, variables);
+      }
+      if (StringUtils.isBlank(model.getName())) {
+        model.setName(FilenameUtils.getBaseName(filename));
+      }
+      model.setFilename(null);
+      // Like a new document, the uploaded copy has no permanent server location yet.
+      model.setChanged();
+      return model;
+    }
+  }
+
+  private void openKettleImport(String filename, Path uploadedFile) {
+    Path extractionDirectory = null;
+    try {
+      if (!HopSecurityUi.check(Permission.FILE_CREATE)
+          || !HopSecurityUi.check(Permission.METADATA_WRITE)) {
+        return;
+      }
+      if (!"zip".equalsIgnoreCase(FilenameUtils.getExtension(filename))) {
+        throw new HopException("Select a ZIP file containing the Kettle/PDI project.");
+      }
+
+      extractionDirectory =
+          Files.createDirectory(
+              getSessionTempDirectory().resolve("kettle-import-" + UUID.randomUUID()));
+      Path sourceFolder = extractZip(uploadedFile, extractionDirectory);
+      invokeGuiPlugin(
+          KETTLE_IMPORT_MENU_ID,
+          "menuToolsImport",
+          new Class<?>[] {String.class},
+          sourceFolder.toString());
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.ImportKettle", e);
+    } finally {
+      if (extractionDirectory != null) {
+        deleteTree(extractionDirectory);
+      }
+    }
+  }
+
+  private void downloadActiveFile(boolean saveAs) {
+    if (!HopSecurityUi.check(Permission.FILE_SAVE)) {
+      return;
+    }
+    try {
+      IHopFileTypeHandler handler = HopGui.getInstance().getActiveFileTypeHandler();
+      if (handler == null) {
+        showError(
+            "HopGui.FileBrowser.Error.NoActiveFile",
+            new HopException("No active pipeline or workflow"));
+        return;
+      }
+
+      byte[] content = serialize(handler);
+      String extension = handler.getFileType().getDefaultFileExtension();
+      String name = suggestedFilename(handler, extension);
+      if (saveAs) {
+        name =
+            new EnterStringDialog(
+                    HopGui.getInstance().getActiveShell(),
+                    name,
+                    BaseMessages.getString(PKG, "HopGui.FileBrowser.SaveAs.Title"),
+                    BaseMessages.getString(PKG, "HopGui.FileBrowser.SaveAs.Label"))
+                .open();
+        if (StringUtils.isBlank(name)) {
+          return;
+        }
+      }
+
+      String downloadName = safeFilename(name, extension);
+      transfer()
+          .download(
+              downloadName,
+              "application/xml",
+              content,
+              () -> {
+                // A tab can be closed or edited while its HTTP download is in flight.
+                if (HopGui.getExplorerPerspective().getItems().stream()
+                    .noneMatch(item -> item.getTypeHandler() == handler)) {
+                  return;
+                }
+                try {
+                  if (saveAs) {
+                    userFileNames.put(handler, downloadName);
+                  }
+                  markDownloaded(handler, content);
+                } catch (HopException e) {
+                  showError("HopGui.FileBrowser.Error.Save", e);
+                }
+              });
+    } catch (Exception e) {
+      showError("HopGui.FileBrowser.Error.Save", e);
+    }
+  }
+
+  static void markDownloaded(IHopFileTypeHandler handler, byte[] downloadedContent)
+      throws HopException {
+    // A browser copy must not mark an existing server file as saved. For an untitled document,
+    // acknowledge only the exact revision sent, leaving any edits made during the transfer dirty.
+    if (StringUtils.isBlank(handler.getFilename())
+        && Arrays.equals(downloadedContent, serialize(handler))) {
+      if (handler.getSubject() instanceof AbstractMeta model) {
+        model.clearChanged();
+        handler.updateGui();
+      }
+    }
+  }
+
+  private String suggestedFilename(IHopFileTypeHandler handler, String extension) {
+    String name =
+        userFileNames.getOrDefault(
+            handler,
+            StringUtils.isNotBlank(handler.getFilename())
+                ? FilenameUtils.getName(handler.getFilename().replace('\\', '/'))
+                : handler.getName());
+    return safeFilename(name, extension);
+  }
+
+  static byte[] serialize(IHopFileTypeHandler handler) throws HopException {
+    String xml;
+    if (handler.getSubject() instanceof PipelineMeta pipelineMeta) {
+      xml = pipelineMeta.getXml(handler.getVariables());
+    } else if (handler.getSubject() instanceof WorkflowMeta workflowMeta) {
+      xml = workflowMeta.getXml(handler.getVariables());
+    } else {
+      throw new HopException("Only pipelines and workflows can currently be saved as user files.");
+    }
+    return (XmlHandler.getXmlHeader(Const.UTF_8) + xml).getBytes(StandardCharsets.UTF_8);
+  }
+
+  static String safeFilename(String name, String extension) {
+    String safeName = FilenameUtils.getName(StringUtils.defaultIfBlank(name, "hop-file"));
+    safeName = safeName.replaceAll("[\\p{Cntrl}\\\\/:*?\"<>|]", "_");
+    if (StringUtils.isNotBlank(extension)
+        && !safeName.toLowerCase().endsWith(extension.toLowerCase())) {
+      safeName += extension;
+    }
+    return safeName;
+  }
+
+  static Path extractZip(Path zipFile, Path directory) throws IOException, HopException {
+    long compressedSize = Files.size(zipFile);
+    if (compressedSize <= 0 || compressedSize > MAX_COMPRESSED_ZIP_SIZE) {
+      throw new HopException("The ZIP file is empty or exceeds the compressed size limit.");
+    }
+
+    Path extractionRoot = directory.toAbsolutePath().normalize();
+    int entries = 0;
+    int fileEntries = 0;
+    long totalSize = 0;
+    byte[] buffer = new byte[8192];
+    Set<Path> extractedPaths = new HashSet<>();
+
+    try (CountingInputStream compressed = new CountingInputStream(Files.newInputStream(zipFile));
+        ZipInputStream zip = new ZipInputStream(compressed)) {
+      ZipEntry entry;
+      while ((entry = zip.getNextEntry()) != null) {
+        if (++entries > MAX_ZIP_ENTRIES) {
+          throw new HopException("The ZIP file contains too many entries.");
+        }
+
+        String entryName = entry.getName().replace('\\', '/');
+        if (entryName.isBlank()
+            || entryName.length() > MAX_ZIP_ENTRY_NAME_LENGTH
+            || entryName.startsWith("/")
+            || entryName.matches("^[A-Za-z]:.*")
+            || entryName.indexOf('\0') >= 0) {
+          throw new HopException("The ZIP file contains an invalid entry path.");
+        }
+        Path relativeEntry = Path.of(entryName).normalize();
+        Path target = extractionRoot.resolve(relativeEntry).normalize();
+        if (relativeEntry.getNameCount() > MAX_ZIP_ENTRY_DEPTH
+            || !target.startsWith(extractionRoot)
+            || !extractedPaths.add(target)) {
+          throw new HopException("The ZIP file contains an invalid or duplicate entry path.");
+        }
+
+        if (entry.isDirectory()) {
+          Files.createDirectories(target);
+        } else {
+          fileEntries++;
+          Files.createDirectories(target.getParent());
+          try (OutputStream output = Files.newOutputStream(target)) {
+            long entrySize = 0;
+            int length;
+            while ((length = zip.read(buffer)) != -1) {
+              if (length == 0) {
+                continue;
+              }
+              entrySize += length;
+              totalSize += length;
+              if (entrySize > MAX_UNCOMPRESSED_ZIP_ENTRY_SIZE) {
+                throw new HopException("A ZIP entry exceeds the uncompressed size limit.");
+              }
+              if (totalSize > MAX_UNCOMPRESSED_ZIP_SIZE) {
+                throw new HopException("The uncompressed ZIP file is larger than 250 MiB.");
+              }
+              if (totalSize > MIN_COMPRESSION_RATIO_SIZE
+                  && compressed.getByteCount() > 0
+                  && totalSize / compressed.getByteCount() > MAX_COMPRESSION_RATIO) {
+                throw new HopException("The ZIP file exceeds the allowed compression ratio.");
+              }
+              output.write(buffer, 0, length);
+            }
+          }
+        }
+        zip.closeEntry();
+      }
+    }
+
+    if (entries == 0 || fileEntries == 0) {
+      throw new HopException("The selected ZIP file is empty.");
+    }
+
+    try (Stream<Path> children = Files.list(extractionRoot)) {
+      List<Path> paths = children.toList();
+      if (paths.size() == 1 && Files.isDirectory(paths.get(0))) {
+        return paths.get(0);
+      }
+    }
+    return extractionRoot;
+  }
+
+  private static Object invokeGuiPlugin(
+      String menuId, String methodName, Class<?>[] parameterTypes, Object... arguments)
+      throws Exception {
+    GuiRegistry guiRegistry = GuiRegistry.getInstance();
+    GuiMenuItem menuItem = guiRegistry.findGuiMenuItem(HopGui.ID_MAIN_MENU, menuId);
+    if (menuItem == null) {
+      throw new HopException("The required Hop plugin is not installed.");
+    }
+
+    HopGui hopGui = HopGui.getInstance();
+    String listenerClassName = menuItem.getListenerClassName();
+    String instanceId = hopGui.getMainMenuWidgets().getInstanceId();
+    Object plugin = guiRegistry.findGuiPluginObject(hopGui.getId(), listenerClassName, instanceId);
+    Class<?> pluginClass = menuItem.getClassLoader().loadClass(listenerClassName);
+    if (plugin == null) {
+      plugin = pluginClass.getConstructor().newInstance();
+      guiRegistry.registerGuiPluginObject(hopGui.getId(), listenerClassName, instanceId, plugin);
+    }
+    try {
+      return pluginClass.getMethod(methodName, parameterTypes).invoke(plugin, arguments);
+    } catch (InvocationTargetException e) {
+      if (e.getCause() instanceof Exception cause) {
+        throw cause;
+      }
+      throw e;
+    }
+  }
+
+  private static Path getSessionTempDirectory() throws IOException {
+    UISession session = RWT.getUISession();
+    Path directory = (Path) session.getAttribute(SESSION_TEMP_DIRECTORY);
+    if (directory != null) {
+      return directory;
+    }
+
+    directory = Files.createTempDirectory(SESSION_TEMP_DIRECTORY_PREFIX);
+    session.setAttribute(SESSION_TEMP_DIRECTORY, directory);
+    Path sessionDirectory = directory;
+    session.addUISessionListener(event -> deleteSessionTempDirectory(sessionDirectory));
+    return directory;
+  }
+
+  private static void deleteSessionTempDirectory(Path directory) {
+    deleteTree(directory);
+  }
+
+  private static void deleteTree(Path directory) {
+    try (Stream<Path> files = Files.walk(directory)) {
+      files
+          .sorted(Comparator.reverseOrder())
+          .forEach(
+              file -> {
+                try {
+                  Files.deleteIfExists(file);
+                } catch (IOException ignored) {
+                  // Best effort during session teardown.
+                }
+              });
+    } catch (IOException ignored) {
+      // Best effort during session teardown.
+    }
+  }
+
+  private static void showError(String messageKey, Exception cause) {
+    String title = BaseMessages.getString(PKG, "HopGui.FileBrowser.Error.Title");
+    String message = BaseMessages.getString(PKG, messageKey);
+    HopGui.getInstance().getLog().logError(message, cause);
+    new ErrorDialog(
+        HopGui.getInstance().getActiveShell(), title, message, new HopException(message));
+  }
+
+  private static final class CountingInputStream extends FilterInputStream {
+    private long byteCount;
+
+    private CountingInputStream(InputStream input) {
+      super(input);
+    }
+
+    @Override
+    public int read() throws IOException {
+      int value = super.read();
+      if (value >= 0) {
+        byteCount++;
+      }
+      return value;
+    }
+
+    @Override
+    public int read(byte[] buffer, int offset, int length) throws IOException {
+      int count = super.read(buffer, offset, length);
+      if (count > 0) {
+        byteCount += count;
+      }
+      return count;
+    }
+
+    private long getByteCount() {
+      return byteCount;
+    }
+  }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/UserFileTransfer.java b/rap/src/main/java/org/apache/hop/ui/hopgui/UserFileTransfer.java
new file mode 100644
index 0000000000..f02a935034
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/UserFileTransfer.java
@@ -0,0 +1,358 @@
+/*
+ * 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;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.LongSupplier;
+import java.util.stream.Stream;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.client.service.UrlLauncher;
+import org.eclipse.rap.rwt.service.ServiceHandler;
+import org.eclipse.rap.rwt.service.ServiceManager;
+import org.eclipse.rap.rwt.service.UISession;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.FileDialog;
+import org.eclipse.swt.widgets.Shell;
+
+/** Browser-backed file selection and download support for Hop Web. */
+final class UserFileTransfer {
+
+  interface UploadListener {
+    void uploaded(String filename, Path uploadedFile) throws Exception;
+
+    void error(Exception exception);
+  }
+
+  static final long DEFAULT_UPLOAD_TIME_LIMIT = Duration.ofMinutes(5).toMillis();
+  static final long DOWNLOAD_TTL_NANOS = Duration.ofMinutes(10).toNanos();
+  static final int MAX_PENDING_DOWNLOADS = 16;
+
+  private static final String DOWNLOAD_SERVICE_PREFIX = UserFileTransfer.class.getName() + ".";
+
+  private final Shell shell;
+  private final Display display;
+  private final ServiceManager serviceManager;
+  private final UISession uiSession;
+  private final String uiSessionId;
+  private final String httpSessionId;
+  private final String downloadServiceId = DOWNLOAD_SERVICE_PREFIX + UUID.randomUUID();
+  private final Path transferDirectory;
+  private final Path uploadDirectory;
+  private final Path downloadDirectory;
+  private final Map<String, Download> downloads = new ConcurrentHashMap<>();
+  private final AtomicBoolean disposed = new AtomicBoolean();
+  private final LongSupplier nanoTime;
+
+  UserFileTransfer(Shell shell, Path sessionTempDirectory) throws IOException {
+    this(shell, sessionTempDirectory, System::nanoTime);
+  }
+
+  UserFileTransfer(Shell shell, Path sessionTempDirectory, LongSupplier nanoTime)
+      throws IOException {
+    this.shell = shell;
+    this.display = shell.getDisplay();
+    this.nanoTime = nanoTime;
+    serviceManager = RWT.getServiceManager();
+    uiSession = RWT.getUISession();
+    uiSessionId = uiSession.getId();
+    httpSessionId = uiSession.getHttpSession().getId();
+    transferDirectory =
+        Files.createDirectory(sessionTempDirectory.resolve("transfer-" + UUID.randomUUID()));
+    uploadDirectory = Files.createDirectory(transferDirectory.resolve("uploads"));
+    downloadDirectory = Files.createDirectory(transferDirectory.resolve("downloads"));
+    serviceManager.registerServiceHandler(downloadServiceId, new DownloadServiceHandler());
+    uiSession.addUISessionListener(event -> dispose());
+  }
+
+  void open(String acceptedExtensions, long uploadSizeLimit, UploadListener listener) {
+    if (isDisposed()) {
+      listener.error(new IOException("The browser file transfer session is closed."));
+      return;
+    }
+
+    Path requestDirectory = null;
+    try {
+      requestDirectory =
+          Files.createDirectory(uploadDirectory.resolve(UUID.randomUUID().toString()));
+      FileDialog dialog = new FileDialog(shell, SWT.OPEN);
+      dialog.setUploadDirectory(requestDirectory.toFile());
+      dialog.setUploadSizeLimit(uploadSizeLimit);
+      dialog.setUploadTimeLimit(DEFAULT_UPLOAD_TIME_LIMIT);
+      if (acceptedExtensions != null && !acceptedExtensions.isBlank()) {
+        dialog.setFilterExtensions(acceptedExtensions.split(","));
+      }
+
+      String uploadedPath = dialog.open();
+      if (uploadedPath == null || uploadedPath.isBlank()) {
+        if (!dialog.getExceptions().isEmpty()) {
+          throw new IOException(
+              "The browser could not upload the selected file.", dialog.getExceptions().get(0));
+        }
+        return;
+      }
+
+      Path uploadedFile = validateUploadedFile(requestDirectory, uploadedPath, uploadSizeLimit);
+      listener.uploaded(safeUploadFilename(dialog.getFileName()), uploadedFile);
+    } catch (Exception e) {
+      listener.error(e);
+    } finally {
+      deleteTree(requestDirectory);
+    }
+  }
+
+  void download(String filename, String contentType, byte[] content) throws IOException {
+    download(filename, contentType, content, null);
+  }
+
+  void download(String filename, String contentType, byte[] content, Runnable onSuccess)
+      throws IOException {
+    Path file = Files.createTempFile(downloadDirectory, "download-", ".tmp");
+    try {
+      Files.write(file, content);
+      registerDownload(filename, contentType, file, onSuccess);
+    } catch (IOException | RuntimeException e) {
+      Files.deleteIfExists(file);
+      throw e;
+    }
+  }
+
+  void download(String filename, String contentType, Path source) throws IOException {
+    Path file = Files.createTempFile(downloadDirectory, "download-", ".tmp");
+    try {
+      Files.copy(source, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+      registerDownload(filename, contentType, file, null);
+    } catch (IOException | RuntimeException e) {
+      Files.deleteIfExists(file);
+      throw e;
+    }
+  }
+
+  private synchronized void registerDownload(
+      String filename, String contentType, Path file, Runnable onSuccess) throws IOException {
+    if (isDisposed()) {
+      throw new IOException("The browser file transfer session is closed.");
+    }
+    removeExpiredDownloads();
+    evictOldestDownloadIfNecessary();
+
+    String token = UUID.randomUUID().toString();
+    Download download =
+        new Download(
+            safeHeaderFilename(filename),
+            contentType,
+            file,
+            nanoTime.getAsLong() + DOWNLOAD_TTL_NANOS,
+            onSuccess);
+    downloads.put(token, download);
+    try {
+      String url =
+          serviceManager.getServiceHandlerUrl(downloadServiceId)
+              + "&token="
+              + URLEncoder.encode(token, StandardCharsets.UTF_8);
+      RWT.getClient().getService(UrlLauncher.class).openURL(url);
+    } catch (RuntimeException e) {
+      downloads.remove(token, download);
+      throw e;
+    }
+  }
+
+  boolean isDisposed() {
+    return disposed.get();
+  }
+
+  synchronized void dispose() {
+    if (disposed.compareAndSet(false, true)) {
+      try {
+        serviceManager.unregisterServiceHandler(downloadServiceId);
+      } finally {
+        downloads.values().forEach(download -> deleteFile(download.file()));
+        downloads.clear();
+        deleteTree(transferDirectory);
+      }
+    }
+  }
+
+  private void removeExpiredDownloads() {
+    long now = nanoTime.getAsLong();
+    downloads.forEach(
+        (token, download) -> {
+          if (download.expiresAtNanos() - now <= 0 && downloads.remove(token, download)) {
+            deleteFile(download.file());
+          }
+        });
+  }
+
+  private void evictOldestDownloadIfNecessary() {
+    if (downloads.size() < MAX_PENDING_DOWNLOADS) {
+      return;
+    }
+    downloads.entrySet().stream()
+        .min(Comparator.comparingLong(entry -> entry.getValue().expiresAtNanos()))
+        .ifPresent(
+            entry -> {
+              if (downloads.remove(entry.getKey(), entry.getValue())) {
+                deleteFile(entry.getValue().file());
+              }
+            });
+  }
+
+  private final class DownloadServiceHandler implements ServiceHandler {
+    @Override
+    public void service(HttpServletRequest request, HttpServletResponse response)
+        throws IOException {
+      if (isDisposed() || !isRequestFromOwningSession(request)) {
+        response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+
+      removeExpiredDownloads();
+      String token = request.getParameter("token");
+      if (token == null || token.isBlank()) {
+        response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+
+      Download download = downloads.remove(token);
+      if (download == null) {
+        response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+
+      try {
+        response.setContentType(download.contentType());
+        response.setContentLengthLong(Files.size(download.file()));
+        response.setHeader("Cache-Control", "private, no-store");
+        response.setHeader("Pragma", "no-cache");
+        response.setHeader("X-Content-Type-Options", "nosniff");
+        response.setHeader("Content-Disposition", contentDisposition(download.filename()));
+        Files.copy(download.file(), response.getOutputStream());
+        response.flushBuffer();
+        notifyDownloadSucceeded(download.onSuccess());
+      } finally {
+        deleteFile(download.file());
+        if (isDisposed()) {
+          // A concurrent session teardown may have encountered this file while it was still open.
+          deleteTree(transferDirectory);
+        }
+      }
+    }
+  }
+
+  private void notifyDownloadSucceeded(Runnable onSuccess) {
+    if (onSuccess == null || isDisposed() || display.isDisposed()) {
+      return;
+    }
+    try {
+      display.asyncExec(
+          () -> {
+            if (!isDisposed() && !shell.isDisposed()) {
+              onSuccess.run();
+            }
+          });
+    } catch (SWTException e) {
+      if (e.code != SWT.ERROR_DEVICE_DISPOSED) {
+        throw e;
+      }
+    }
+  }
+
+  static Path validateUploadedFile(Path requestDirectory, String uploadedPath, long sizeLimit)
+      throws IOException {
+    Path directory = requestDirectory.toAbsolutePath().normalize();
+    Path uploadedFile = Path.of(uploadedPath).toAbsolutePath().normalize();
+    if (!uploadedFile.startsWith(directory)
+        || !directory.equals(uploadedFile.getParent())
+        || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)
+        || !Files.isRegularFile(uploadedFile, LinkOption.NOFOLLOW_LINKS)
+        || Files.size(uploadedFile) > sizeLimit) {
+      throw new IOException("The uploaded file is invalid or exceeds the configured size limit.");
+    }
+    return uploadedFile;
+  }
+
+  static String safeUploadFilename(String filename) {
+    if (filename == null) {
+      return "hop-file";
+    }
+    String basename = filename.replace('\\', '/');
+    basename = basename.substring(basename.lastIndexOf('/') + 1);
+    return safeHeaderFilename(basename);
+  }
+
+  private boolean isRequestFromOwningSession(HttpServletRequest request) {
+    HttpSession requestHttpSession = request.getSession(false);
+    if (requestHttpSession == null || !httpSessionId.equals(requestHttpSession.getId())) {
+      return false;
+    }
+    try {
+      return uiSessionId.equals(RWT.getUISession().getId());
+    } catch (IllegalStateException e) {
+      return false;
+    }
+  }
+
+  static String safeHeaderFilename(String filename) {
+    String safe = filename == null ? "hop-file" : filename;
+    safe = safe.replaceAll("[\\x00-\\x1f\\x7f\\\\/\"]", "_");
+    return safe.isBlank() ? "hop-file" : safe;
+  }
+
+  static String contentDisposition(String filename) {
+    String ascii = filename.replaceAll("[^\\x20-\\x7E]", "_");
+    String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
+    return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded;
+  }
+
+  private static void deleteFile(Path file) {
+    try {
+      Files.deleteIfExists(file);
+    } catch (IOException ignored) {
+      // Best effort cleanup; the enclosing session directory is removed on session teardown.
+    }
+  }
+
+  private static void deleteTree(Path directory) {
+    if (directory == null || !Files.exists(directory)) {
+      return;
+    }
+    try (Stream<Path> files = Files.walk(directory)) {
+      files.sorted(Comparator.reverseOrder()).forEach(UserFileTransfer::deleteFile);
+    } catch (IOException ignored) {
+      // Best effort cleanup during request or session teardown.
+    }
+  }
+
+  private record Download(
+      String filename, String contentType, Path file, long expiresAtNanos, Runnable onSuccess) {}
+}
diff --git a/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebUserFilePluginTest.java b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebUserFilePluginTest.java
new file mode 100644
index 0000000000..52bc10fbd0
--- /dev/null
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebUserFilePluginTest.java
@@ -0,0 +1,286 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HopWebUserFilePluginTest {
+
+  @TempDir Path tempDirectory;
+
+  @BeforeAll
+  static void initializeLogging() {
+    HopLogStore.init();
+  }
+
+  @Test
+  void uploadedPipelineIsUnsavedAndIndependentOfTemporaryFile() throws Exception {
+    Path upload = tempDirectory.resolve("pipeline with spaces #1.hpl");
+    Files.writeString(upload, "<pipeline><info><name>Uploaded pipeline</name></info></pipeline>");
+
+    PipelineMeta model =
+        (PipelineMeta)
+            HopWebUserFilePlugin.readUploadedFile(
+                upload.getFileName().toString(),
+                upload,
+                new MemoryMetadataProvider(),
+                new Variables());
+    Files.delete(upload);
+
+    assertNull(model.getFilename());
+    assertEquals("Uploaded pipeline", model.getName());
+    assertTrue(model.hasChanged());
+    assertTrue(model.getXml(new Variables()).contains("Uploaded pipeline"));
+  }
+
+  @Test
+  void uploadedWorkflowIsUnsavedAndIndependentOfTemporaryFile() throws Exception {
+    Path upload = tempDirectory.resolve("workflow.hwf");
+    Files.writeString(upload, "<workflow><name>Uploaded workflow</name></workflow>");
+
+    WorkflowMeta model =
+        (WorkflowMeta)
+            HopWebUserFilePlugin.readUploadedFile(
+                "workflow.hwf", upload, new MemoryMetadataProvider(), new Variables());
+    Files.delete(upload);
+
+    assertNull(model.getFilename());
+    assertEquals("Uploaded workflow", model.getName());
+    assertTrue(model.hasChanged());
+    assertTrue(model.getXml(new Variables()).contains("Uploaded workflow"));
+  }
+
+  @Test
+  void rejectsWrongXmlTypeAndMalformedUpload() throws Exception {
+    Path upload = tempDirectory.resolve("invalid.hpl");
+    Files.writeString(upload, "<workflow><name>Wrong type</name></workflow>");
+    assertThrows(
+        HopException.class,
+        () ->
+            HopWebUserFilePlugin.readUploadedFile(
+                "invalid.hpl", upload, new MemoryMetadataProvider(), new Variables()));
+
+    Files.writeString(upload, "<pipeline>");
+    assertThrows(
+        HopException.class,
+        () ->
+            HopWebUserFilePlugin.readUploadedFile(
+                "invalid.hpl", upload, new MemoryMetadataProvider(), new Variables()));
+  }
+
+  @Test
+  void successfulDownloadClearsOnlyTheUploadedRevision() throws Exception {
+    PipelineMeta model = new PipelineMeta();
+    model.setName("Downloaded pipeline");
+    model.setChanged();
+    IHopFileTypeHandler handler = handler(model);
+
+    HopWebUserFilePlugin.markDownloaded(handler, HopWebUserFilePlugin.serialize(handler));
+
+    assertFalse(model.hasChanged());
+    assertNull(model.getFilename());
+    verify(handler).updateGui();
+  }
+
+  @Test
+  void successfulWorkflowDownloadClearsItsChangedFlag() throws Exception {
+    WorkflowMeta model = new WorkflowMeta();
+    model.setName("Downloaded workflow");
+    model.setChanged();
+    IHopFileTypeHandler handler = mock(IHopFileTypeHandler.class);
+    when(handler.getSubject()).thenReturn(model);
+    when(handler.getVariables()).thenReturn(new Variables());
+
+    HopWebUserFilePlugin.markDownloaded(handler, HopWebUserFilePlugin.serialize(handler));
+
+    assertFalse(model.hasChanged());
+    verify(handler).updateGui();
+  }
+
+  @Test
+  void downloadDoesNotDiscardEditsMadeWhileTransferWasPending() throws Exception {
+    PipelineMeta model = new PipelineMeta();
+    model.setName("Before download");
+    IHopFileTypeHandler handler = handler(model);
+    byte[] snapshot = HopWebUserFilePlugin.serialize(handler);
+    model.setName("Edited during download");
+    model.setChanged();
+
+    HopWebUserFilePlugin.markDownloaded(handler, snapshot);
+
+    assertTrue(model.hasChanged());
+    verify(handler, never()).updateGui();
+  }
+
+  @Test
+  void downloadingCopyDoesNotMarkServerFileAsSaved() throws Exception {
+    PipelineMeta model = new PipelineMeta();
+    model.setName("Server pipeline");
+    model.setChanged();
+    IHopFileTypeHandler handler = handler(model);
+    when(handler.getFilename()).thenReturn("project/server.hpl");
+
+    HopWebUserFilePlugin.markDownloaded(handler, HopWebUserFilePlugin.serialize(handler));
+
+    assertTrue(model.hasChanged());
+    verify(handler, never()).updateGui();
+  }
+
+  private static IHopFileTypeHandler handler(PipelineMeta model) {
+    IHopFileTypeHandler handler = mock(IHopFileTypeHandler.class);
+    when(handler.getSubject()).thenReturn(model);
+    when(handler.getVariables()).thenReturn(new Variables());
+    return handler;
+  }
+
+  @Test
+  void browserActionsAreRegisteredInsideTheFileUserMenu() throws Exception {
+    assertEquals(
+        HopGui.ID_MAIN_MENU,
+        HopWebUserFilePlugin.class
+            .getDeclaredMethod("menuFileUser")
+            .getAnnotation(GuiMenuElement.class)
+            .parentId());
+    assertUserFileMenuAction("newFile", "15010-menu-file-user-new");
+    assertUserFileMenuAction("openFile", "15020-menu-file-user-open");
+    assertUserFileMenuAction("saveFile", HopGui.ID_MAIN_MENU_FILE_USER_SAVE);
+    assertUserFileMenuAction("saveFileAs", HopGui.ID_MAIN_MENU_FILE_USER_SAVE_AS);
+    assertUserFileMenuAction("exportToSvg", HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG);
+    assertUserFileMenuAction("exportProjectZip", HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT);
+    assertUserFileMenuAction("importFromKettleZip", HopGui.ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE);
+    assertEquals(
+        "ui/images/kettle-logo.svg",
+        HopWebUserFilePlugin.class
+            .getDeclaredMethod("importFromKettleZip")
+            .getAnnotation(GuiMenuElement.class)
+            .image());
+  }
+
+  @Test
+  void extractsSingleProjectFolder() throws Exception {
+    Path zip = zip("my-project/example.ktr", "kettle");
+    Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-project"));
+
+    Path source = HopWebUserFilePlugin.extractZip(zip, extractionDirectory);
+
+    assertEquals(extractionDirectory.resolve("my-project"), source);
+    assertEquals("kettle", Files.readString(source.resolve("example.ktr"), StandardCharsets.UTF_8));
+  }
+
+  @Test
+  void rejectsPathsOutsideExtractionDirectory() throws Exception {
+    Path zip = zip("../outside.ktr", "unsafe");
+    Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-traversal"));
+
+    HopException exception =
+        assertThrows(
+            HopException.class, () -> HopWebUserFilePlugin.extractZip(zip, extractionDirectory));
+    assertFalse(Files.exists(tempDirectory.resolve("outside.ktr")));
+    assertFalse(exception.getMessage().contains("outside.ktr"));
+  }
+
+  @Test
+  void rejectsWindowsStylePathTraversal() throws Exception {
+    Path zip = zip("..\\outside.ktr", "unsafe");
+    Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-windows"));
+
+    assertThrows(
+        HopException.class, () -> HopWebUserFilePlugin.extractZip(zip, extractionDirectory));
+    assertFalse(Files.exists(tempDirectory.resolve("outside.ktr")));
+  }
+
+  @Test
+  void rejectsAbsoluteAndDriveQualifiedPaths() throws Exception {
+    Path unixZip = zip("/outside.ktr", "unsafe");
+    Path driveZip = zip("C:/outside.ktr", "unsafe");
+    Path unixDirectory = Files.createDirectory(tempDirectory.resolve("extract-absolute"));
+    Path driveDirectory = Files.createDirectory(tempDirectory.resolve("extract-drive"));
+
+    assertThrows(HopException.class, () -> HopWebUserFilePlugin.extractZip(unixZip, unixDirectory));
+    assertThrows(
+        HopException.class, () -> HopWebUserFilePlugin.extractZip(driveZip, driveDirectory));
+  }
+
+  @Test
+  void rejectsHighlyCompressedZipBomb() throws Exception {
+    Path zip = zip("project/repeated.ktr", "0".repeat(2 * 1024 * 1024));
+    Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-ratio"));
+
+    assertThrows(
+        HopException.class, () -> HopWebUserFilePlugin.extractZip(zip, extractionDirectory));
+  }
+
+  @Test
+  void sanitizesDownloadHeaders() {
+    String filename = UserFileTransfer.safeHeaderFilename("report\r\n\".svg");
+
+    assertFalse(filename.contains("\r"));
+    assertFalse(filename.contains("\n"));
+    assertFalse(filename.contains("\""));
+    assertTrue(UserFileTransfer.contentDisposition(filename).startsWith("attachment;"));
+  }
+
+  private Path zip(String name, String content) throws Exception {
+    ByteArrayOutputStream output = new ByteArrayOutputStream();
+    try (ZipOutputStream zip = new ZipOutputStream(output)) {
+      zip.putNextEntry(new ZipEntry(name));
+      zip.write(content.getBytes(StandardCharsets.UTF_8));
+      zip.closeEntry();
+    }
+    Path zipFile = tempDirectory.resolve(UUID.randomUUID() + ".zip");
+    Files.write(zipFile, output.toByteArray());
+    return zipFile;
+  }
+
+  private static void assertUserFileMenuAction(String methodName, String id) throws Exception {
+    Method method = HopWebUserFilePlugin.class.getDeclaredMethod(methodName);
+    GuiMenuElement menu = method.getAnnotation(GuiMenuElement.class);
+    assertNotNull(menu);
+    assertEquals(id, menu.id());
+    assertEquals(HopWebUserFilePlugin.ID_MAIN_MENU_FILE_USER, menu.parentId());
+  }
+}
diff --git a/rap/src/test/java/org/apache/hop/ui/hopgui/UserFileTransferTest.java b/rap/src/test/java/org/apache/hop/ui/hopgui/UserFileTransferTest.java
new file mode 100644
index 0000000000..e95f4b3de2
--- /dev/null
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/UserFileTransferTest.java
@@ -0,0 +1,649 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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 static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.atomic.AtomicLong;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.client.Client;
+import org.eclipse.rap.rwt.client.service.UrlLauncher;
+import org.eclipse.rap.rwt.service.ServiceHandler;
+import org.eclipse.rap.rwt.service.ServiceManager;
+import org.eclipse.rap.rwt.service.UISession;
+import org.eclipse.rap.rwt.service.UISessionEvent;
+import org.eclipse.rap.rwt.service.UISessionListener;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.FileDialog;
+import org.eclipse.swt.widgets.Shell;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+class UserFileTransferTest {
+
+  private static final byte[] CONTENT = "<pipeline/>".getBytes(StandardCharsets.UTF_8);
+
+  @TempDir Path tempDirectory;
+
+  private final AtomicLong nanoTime = new AtomicLong();
+  private final List<String> urls = new ArrayList<>();
+  private final Queue<Runnable> uiCallbacks = new ArrayDeque<>();
+
+  private MockedStatic<RWT> rwt;
+  private UserFileTransfer transfer;
+  private Shell shell;
+  private Display display;
+  private ServiceManager serviceManager;
+  private UISession uiSession;
+  private HttpSession httpSession;
+  private UrlLauncher launcher;
+  private ServiceHandler handler;
+  private String serviceId;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    shell = mock(Shell.class);
+    display = mock(Display.class);
+    serviceManager = mock(ServiceManager.class);
+    uiSession = mock(UISession.class);
+    httpSession = mock(HttpSession.class);
+    Client client = mock(Client.class);
+    launcher = mock(UrlLauncher.class);
+    when(shell.getDisplay()).thenReturn(display);
+    when(uiSession.getId()).thenReturn("owning-ui-session");
+    when(uiSession.getHttpSession()).thenReturn(httpSession);
+    when(httpSession.getId()).thenReturn("owning-http-session");
+    when(client.getService(UrlLauncher.class)).thenReturn(launcher);
+    when(serviceManager.getServiceHandlerUrl(anyString())).thenReturn("/hop?service=download");
+    doAnswer(
+            invocation -> {
+              urls.add(invocation.getArgument(0));
+              return null;
+            })
+        .when(launcher)
+        .openURL(anyString());
+    doAnswer(
+            invocation -> {
+              uiCallbacks.add(invocation.getArgument(0));
+              return null;
+            })
+        .when(display)
+        .asyncExec(any(Runnable.class));
+
+    rwt = mockStatic(RWT.class);
+    rwt.when(RWT::getServiceManager).thenReturn(serviceManager);
+    rwt.when(RWT::getUISession).thenReturn(uiSession);
+    rwt.when(RWT::getClient).thenReturn(client);
+    transfer = new UserFileTransfer(shell, tempDirectory, nanoTime::get);
+    ArgumentCaptor<ServiceHandler> handlerCaptor = ArgumentCaptor.forClass(ServiceHandler.class);
+    ArgumentCaptor<String> idCaptor = ArgumentCaptor.forClass(String.class);
+    verify(serviceManager).registerServiceHandler(idCaptor.capture(), handlerCaptor.capture());
+    handler = handlerCaptor.getValue();
+    serviceId = idCaptor.getValue();
+  }
+
+  @AfterEach
+  void tearDown() {
+    try {
+      if (transfer != null && !transfer.isDisposed()) {
+        transfer.dispose();
+      }
+    } finally {
+      if (rwt != null) {
+        rwt.close();
+      }
+    }
+  }
+
+  @Test
+  void servesContentWithSafeHeadersAndConsumesTheTokenOnce() throws Exception {
+    String token = download("r\u00e9sum\u00e9\r\n\".hpl", null);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    TestOutputStream output = new TestOutputStream();
+    when(response.getOutputStream()).thenReturn(output);
+
+    handler.service(request(token), response);
+
+    assertArrayEquals(CONTENT, output.bytes.toByteArray());
+    verify(response).setContentType("application/xml");
+    verify(response).setContentLengthLong(CONTENT.length);
+    verify(response).setHeader("Cache-Control", "private, no-store");
+    verify(response).setHeader("Pragma", "no-cache");
+    verify(response).setHeader("X-Content-Type-Options", "nosniff");
+    verify(response)
+        .setHeader(
+            "Content-Disposition",
+            "attachment; filename=\"r_sum____.hpl\"; filename*=UTF-8''r%C3%A9sum%C3%A9___.hpl");
+    verify(response).flushBuffer();
+    assertEquals(0, pendingFileCount());
+    assertNotFound(token);
+  }
+
+  @Test
+  void rejectsAnotherHttpSessionWithoutConsumingTheToken() throws Exception {
+    String token = download("pipeline.hpl", null);
+    HttpSession foreign = mock(HttpSession.class);
+    when(foreign.getId()).thenReturn("foreign-http-session");
+    HttpServletRequest request = request(token);
+    when(request.getSession(false)).thenReturn(foreign);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+
+    handler.service(request, response);
+
+    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+    assertEquals(1, pendingFileCount());
+    assertDownloads(token);
+  }
+
+  @Test
+  void rejectsAnotherUiSessionWithinTheSameHttpSession() throws Exception {
+    String token = download("pipeline.hpl", null);
+    UISession foreign = mock(UISession.class);
+    when(foreign.getId()).thenReturn("foreign-ui-session");
+    rwt.when(RWT::getUISession).thenReturn(foreign);
+
+    assertNotFound(token);
+
+    assertEquals(1, pendingFileCount());
+    rwt.when(RWT::getUISession).thenReturn(uiSession);
+    assertDownloads(token);
+  }
+
+  @Test
+  void rejectsRequestsWithoutAnHttpSession() throws Exception {
+    String token = download("pipeline.hpl", null);
+    HttpServletRequest request = request(token);
+    when(request.getSession(false)).thenReturn(null);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+
+    handler.service(request, response);
+
+    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+    verify(request, never()).getSession(true);
+    assertEquals(1, pendingFileCount());
+  }
+
+  @Test
+  void rejectsRequestsWithoutAnActiveUiSession() throws Exception {
+    String token = download("pipeline.hpl", null);
+    rwt.when(RWT::getUISession).thenThrow(new IllegalStateException("Session is gone"));
+
+    assertNotFound(token);
+
+    assertEquals(1, pendingFileCount());
+  }
+
+  @Test
+  void rejectsMissingBlankAndUnknownTokens() throws Exception {
+    download("pipeline.hpl", null);
+
+    assertNotFound(null);
+    assertNotFound(" ");
+    assertNotFound("unknown-token");
+
+    assertEquals(1, pendingFileCount());
+  }
+
+  @Test
+  void expiresAndDeletesPendingDownloadsAtTheTtlBoundary() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    String token = download("pipeline.hpl", callback);
+    nanoTime.set(UserFileTransfer.DOWNLOAD_TTL_NANOS);
+
+    assertNotFound(token);
+
+    assertEquals(0, pendingFileCount());
+    verify(callback, never()).run();
+    assertTrue(uiCallbacks.isEmpty());
+  }
+
+  @Test
+  void registeringADownloadAlsoRemovesExpiredFiles() throws Exception {
+    download("expired.hpl", null);
+    nanoTime.set(UserFileTransfer.DOWNLOAD_TTL_NANOS);
+
+    String current = download("current.hpl", null);
+
+    assertEquals(1, pendingFileCount());
+    assertDownloads(current);
+  }
+
+  @Test
+  void evictsTheOldestDownloadAboveThePendingLimit() throws Exception {
+    List<String> tokens = new ArrayList<>();
+    for (int index = 0; index <= UserFileTransfer.MAX_PENDING_DOWNLOADS; index++) {
+      nanoTime.incrementAndGet();
+      tokens.add(download("pipeline-" + index + ".hpl", null));
+    }
+
+    assertEquals(UserFileTransfer.MAX_PENDING_DOWNLOADS, pendingFileCount());
+    assertNotFound(tokens.getFirst());
+    for (String token : tokens.subList(1, tokens.size())) {
+      assertDownloads(token);
+    }
+    assertEquals(0, pendingFileCount());
+  }
+
+  @Test
+  void notifiesSuccessOnlyOnTheUiThreadAfterTheResponseHasFlushed() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    String token = download("pipeline.hpl", callback);
+    verify(callback, never()).run();
+    assertTrue(uiCallbacks.isEmpty());
+
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getOutputStream()).thenReturn(new TestOutputStream());
+    doAnswer(
+            invocation -> {
+              assertTrue(uiCallbacks.isEmpty());
+              return null;
+            })
+        .when(response)
+        .flushBuffer();
+    handler.service(request(token), response);
+
+    verify(callback, never()).run();
+    assertEquals(1, uiCallbacks.size());
+    uiCallbacks.remove().run();
+    verify(callback).run();
+  }
+
+  @Test
+  void failedStreamingDeletesTheFileWithoutNotifyingSuccess() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    String token = download("pipeline.hpl", callback);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    TestOutputStream output = new TestOutputStream();
+    output.failWrite = true;
+    when(response.getOutputStream()).thenReturn(output);
+
+    assertThrows(IOException.class, () -> handler.service(request(token), response));
+
+    assertEquals(0, pendingFileCount());
+    assertNotFound(token);
+    verify(response, never()).flushBuffer();
+    verify(callback, never()).run();
+    assertTrue(uiCallbacks.isEmpty());
+  }
+
+  @Test
+  void failedFlushDoesNotNotifySuccess() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    String token = download("pipeline.hpl", callback);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getOutputStream()).thenReturn(new TestOutputStream());
+    doThrow(new IOException("Connection closed")).when(response).flushBuffer();
+
+    assertThrows(IOException.class, () -> handler.service(request(token), response));
+
+    assertEquals(0, pendingFileCount());
+    verify(callback, never()).run();
+    assertTrue(uiCallbacks.isEmpty());
+  }
+
+  @Test
+  void discardsQueuedCallbacksAfterSessionDisposal() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    assertDownloads(download("pipeline.hpl", callback));
+
+    transfer.dispose();
+    uiCallbacks.remove().run();
+
+    verify(callback, never()).run();
+  }
+
+  @Test
+  void discardsQueuedCallbacksWhenTheShellCloses() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    assertDownloads(download("pipeline.hpl", callback));
+
+    when(shell.isDisposed()).thenReturn(true);
+    uiCallbacks.remove().run();
+
+    verify(callback, never()).run();
+  }
+
+  @Test
+  void toleratesDisplayDisposalWhileSchedulingSuccess() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    doThrow(new SWTException(SWT.ERROR_DEVICE_DISPOSED))
+        .when(display)
+        .asyncExec(any(Runnable.class));
+
+    assertDownloads(download("pipeline.hpl", callback));
+
+    verify(callback, never()).run();
+    assertEquals(0, pendingFileCount());
+  }
+
+  @Test
+  void cleansUpWhenTheBrowserCannotLaunchTheDownload() throws Exception {
+    doThrow(new IllegalStateException("Client is unavailable")).when(launcher).openURL(anyString());
+
+    assertThrows(
+        IllegalStateException.class,
+        () -> transfer.download("pipeline.hpl", "application/xml", CONTENT));
+
+    assertEquals(0, pendingFileCount());
+  }
+
+  @Test
+  void cleansUpWhenCreatingTheDownloadUrlFails() throws Exception {
+    when(serviceManager.getServiceHandlerUrl(anyString()))
+        .thenThrow(new IllegalStateException("Application context is closed"));
+
+    assertThrows(
+        IllegalStateException.class,
+        () -> transfer.download("pipeline.hpl", "application/xml", CONTENT));
+
+    assertEquals(0, pendingFileCount());
+    assertTrue(urls.isEmpty());
+  }
+
+  @Test
+  void sessionDisposalDuringStreamingSuppressesSuccessAndCleansFiles() throws Exception {
+    Runnable callback = mock(Runnable.class);
+    String token = download("pipeline.hpl", callback);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getOutputStream()).thenReturn(new TestOutputStream());
+    doAnswer(
+            invocation -> {
+              transfer.dispose();
+              return null;
+            })
+        .when(response)
+        .flushBuffer();
+
+    handler.service(request(token), response);
+
+    verify(callback, never()).run();
+    assertTrue(uiCallbacks.isEmpty());
+    try (var paths = Files.list(tempDirectory)) {
+      assertEquals(0, paths.count());
+    }
+  }
+
+  @Test
+  void cleansUpWhenCopyingADownloadSourceFails() throws Exception {
+    assertThrows(
+        IOException.class,
+        () ->
+            transfer.download("pipeline.hpl", "application/xml", tempDirectory.resolve("missing")));
+
+    assertEquals(0, pendingFileCount());
+    assertTrue(urls.isEmpty());
+  }
+
+  @Test
+  void sessionDestructionUnregistersTheServiceAndRemovesAllFiles() throws Exception {
+    String token = download("pipeline.hpl", null);
+    ArgumentCaptor<UISessionListener> listener = ArgumentCaptor.forClass(UISessionListener.class);
+    verify(uiSession).addUISessionListener(listener.capture());
+
+    listener.getValue().beforeDestroy(new UISessionEvent(uiSession));
+    transfer.dispose();
+
+    assertTrue(transfer.isDisposed());
+    verify(serviceManager, times(1)).unregisterServiceHandler(serviceId);
+    try (var paths = Files.list(tempDirectory)) {
+      assertEquals(0, paths.count());
+    }
+    assertNotFound(token);
+    assertThrows(
+        IOException.class, () -> transfer.download("pipeline.hpl", "application/xml", CONTENT));
+  }
+
+  @Test
+  void stillCleansFilesIfUnregisteringTheServiceFails() throws Exception {
+    download("pipeline.hpl", null);
+    doThrow(new IllegalStateException("Application context is closed"))
+        .when(serviceManager)
+        .unregisterServiceHandler(serviceId);
+
+    assertThrows(IllegalStateException.class, transfer::dispose);
+
+    assertTrue(transfer.isDisposed());
+    try (var paths = Files.list(tempDirectory)) {
+      assertEquals(0, paths.count());
+    }
+  }
+
+  @Test
+  void acceptsOnlyARegularDirectChildWithinTheSizeLimit() throws Exception {
+    Path directory = Files.createDirectory(tempDirectory.resolve("request"));
+    Path file = Files.write(directory.resolve("pipeline.hpl"), CONTENT);
+
+    assertEquals(
+        file.toAbsolutePath().normalize(),
+        UserFileTransfer.validateUploadedFile(directory, file.toString(), CONTENT.length));
+    assertThrows(
+        IOException.class,
+        () ->
+            UserFileTransfer.validateUploadedFile(directory, file.toString(), CONTENT.length - 1));
+    assertThrows(
+        IOException.class,
+        () ->
+            UserFileTransfer.validateUploadedFile(directory, directory.toString(), CONTENT.length));
+  }
+
+  @Test
+  void rejectsOutsideNestedAndTraversalUploadPathsWithoutDeletingThem() throws Exception {
+    Path directory = Files.createDirectory(tempDirectory.resolve("request"));
+    Path outside = Files.write(tempDirectory.resolve("outside.hpl"), CONTENT);
+    Path nestedDirectory = Files.createDirectory(directory.resolve("nested"));
+    Path nested = Files.write(nestedDirectory.resolve("pipeline.hpl"), CONTENT);
+    Path siblingDirectory = Files.createDirectory(tempDirectory.resolve("request-sibling"));
+    Path sibling = Files.write(siblingDirectory.resolve("pipeline.hpl"), CONTENT);
+    for (Path file : List.of(outside, nested, sibling, directory.resolve("../outside.hpl"))) {
+      assertThrows(
+          IOException.class,
+          () -> UserFileTransfer.validateUploadedFile(directory, file.toString(), CONTENT.length));
+    }
+
+    assertArrayEquals(CONTENT, Files.readAllBytes(outside));
+    assertArrayEquals(CONTENT, Files.readAllBytes(nested));
+    assertArrayEquals(CONTENT, Files.readAllBytes(sibling));
+  }
+
+  @Test
+  void rejectsSymbolicLinkUploads() throws Exception {
+    Path directory = Files.createDirectory(tempDirectory.resolve("request"));
+    Path outside = Files.write(tempDirectory.resolve("outside.hpl"), CONTENT);
+    Path link = directory.resolve("pipeline.hpl");
+    try {
+      Files.createSymbolicLink(link, outside);
+    } catch (IOException | UnsupportedOperationException | SecurityException e) {
+      assumeTrue(false, "Symbolic link creation is unavailable: " + e.getClass().getSimpleName());
+    }
+
+    assertThrows(
+        IOException.class,
+        () -> UserFileTransfer.validateUploadedFile(directory, link.toString(), CONTENT.length));
+    assertArrayEquals(CONTENT, Files.readAllBytes(outside));
+  }
+
+  @Test
+  void usesASanitizedBasenameForUploadedFiles() {
+    assertEquals("pipeline.hpl", UserFileTransfer.safeUploadFilename("C:\\fakepath\\pipeline.hpl"));
+    assertEquals("pipeline.hpl", UserFileTransfer.safeUploadFilename("../../pipeline.hpl"));
+    assertEquals("pipeline__.hpl", UserFileTransfer.safeUploadFilename("pipeline\r\n.hpl"));
+    assertEquals("hop-file", UserFileTransfer.safeUploadFilename(null));
+    assertEquals("hop-file", UserFileTransfer.safeUploadFilename("/"));
+  }
+
+  @Test
+  void rejectsAndCleansAnOutsideUploadWithoutDeletingTheForeignFile() throws Exception {
+    Path outside = Files.write(tempDirectory.resolve("outside.hpl"), CONTENT);
+    UserFileTransfer.UploadListener listener = mock(UserFileTransfer.UploadListener.class);
+    try (MockedConstruction<FileDialog> dialogs =
+        mockConstruction(
+            FileDialog.class,
+            (dialog, context) -> when(dialog.open()).thenReturn(outside.toString()))) {
+      transfer.open("*.hpl", CONTENT.length, listener);
+
+      verify(listener).error(any(IOException.class));
+      verify(listener, never()).uploaded(anyString(), any(Path.class));
+      ArgumentCaptor<java.io.File> directory = ArgumentCaptor.forClass(java.io.File.class);
+      verify(dialogs.constructed().getFirst()).setUploadDirectory(directory.capture());
+      assertFalse(Files.exists(directory.getValue().toPath()));
+    }
+    assertArrayEquals(CONTENT, Files.readAllBytes(outside));
+  }
+
+  @Test
+  void cancellingAnUploadCleansItsRequestDirectory() throws Exception {
+    UserFileTransfer.UploadListener listener = mock(UserFileTransfer.UploadListener.class);
+    try (MockedConstruction<FileDialog> dialogs = mockConstruction(FileDialog.class)) {
+      transfer.open("*.hpl", CONTENT.length, listener);
+
+      verify(listener, never()).error(any(Exception.class));
+      verify(listener, never()).uploaded(anyString(), any(Path.class));
+      ArgumentCaptor<java.io.File> directory = ArgumentCaptor.forClass(java.io.File.class);
+      verify(dialogs.constructed().getFirst()).setUploadDirectory(directory.capture());
+      assertFalse(Files.exists(directory.getValue().toPath()));
+    }
+  }
+
+  @Test
+  void cleansTheUploadedFileEvenWhenTheListenerFails() throws Exception {
+    UserFileTransfer.UploadListener listener = mock(UserFileTransfer.UploadListener.class);
+    doThrow(new IOException("Invalid pipeline"))
+        .when(listener)
+        .uploaded(anyString(), any(Path.class));
+    List<Path> uploadDirectories = new ArrayList<>();
+    try (MockedConstruction<FileDialog> dialogs =
+        mockConstruction(
+            FileDialog.class,
+            (dialog, context) -> {
+              doAnswer(
+                      invocation -> {
+                        uploadDirectories.add(((java.io.File) invocation.getArgument(0)).toPath());
+                        return null;
+                      })
+                  .when(dialog)
+                  .setUploadDirectory(any(java.io.File.class));
+              when(dialog.open())
+                  .thenAnswer(
+                      invocation ->
+                          Files.write(uploadDirectories.getFirst().resolve("pipeline.hpl"), CONTENT)
+                              .toString());
+              when(dialog.getFileName()).thenReturn("C:\\fakepath\\pipeline.hpl");
+            })) {
+      transfer.open("*.hpl", CONTENT.length, listener);
+
+      assertEquals(1, dialogs.constructed().size());
+      verify(listener)
+          .uploaded("pipeline.hpl", uploadDirectories.getFirst().resolve("pipeline.hpl"));
+      verify(listener).error(any(IOException.class));
+    }
+    assertFalse(Files.exists(uploadDirectories.getFirst()));
+  }
+
+  private String download(String filename, Runnable onSuccess) throws IOException {
+    transfer.download(filename, "application/xml", CONTENT, onSuccess);
+    String url = urls.getLast();
+    return url.substring(url.indexOf("&token=") + "&token=".length());
+  }
+
+  private HttpServletRequest request(String token) {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getSession(false)).thenReturn(httpSession);
+    when(request.getParameter("token")).thenReturn(token);
+    return request;
+  }
+
+  private void assertNotFound(String token) throws Exception {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    handler.service(request(token), response);
+    verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+    verify(response, never()).getOutputStream();
+  }
+
+  private void assertDownloads(String token) throws Exception {
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    TestOutputStream output = new TestOutputStream();
+    when(response.getOutputStream()).thenReturn(output);
+    handler.service(request(token), response);
+    assertArrayEquals(CONTENT, output.bytes.toByteArray());
+    verify(response).flushBuffer();
+  }
+
+  private long pendingFileCount() throws IOException {
+    try (var files = Files.walk(tempDirectory)) {
+      return files.filter(Files::isRegularFile).count();
+    }
+  }
+
+  private static class TestOutputStream extends ServletOutputStream {
+    private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    private boolean failWrite;
+
+    @Override
+    public void write(int value) throws IOException {
+      if (failWrite) {
+        throw new IOException("Connection closed");
+      }
+      bytes.write(value);
+    }
+
+    @Override
+    public boolean isReady() {
+      return true;
+    }
+
+    @Override
+    public void setWriteListener(WriteListener listener) {}
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
index 22c531aebf..c7392c7cc6 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
@@ -48,12 +48,18 @@ import org.eclipse.swt.widgets.Shell;
 public class GuiMenuWidgets extends BaseGuiWidgets {
 
   private Map<String, MenuItem> menuItemMap;
+  private Map<String, MenuItem> menuSeparatorMap;
+  private Map<String, Menu> menuParentMap;
+  private Map<String, GuiMenuItem> menuDefinitionMap;
   private Map<String, KeyboardShortcut> shortcutMap;
   private Map<String, Boolean> menuEnabledMap;
 
   public GuiMenuWidgets() {
     super(UUID.randomUUID().toString());
     this.menuItemMap = new HashMap<>();
+    this.menuSeparatorMap = new HashMap<>();
+    this.menuParentMap = new HashMap<>();
+    this.menuDefinitionMap = new HashMap<>();
     this.shortcutMap = new HashMap<>();
     this.menuEnabledMap = new HashMap<>();
   }
@@ -151,40 +157,7 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
         GuiRegistry.getInstance().findChildGuiMenuItems(root, guiMenuItem.getId());
 
     if (children.isEmpty()) {
-
-      if (guiMenuItem.isAddingSeparator()) {
-        new MenuItem(parentMenu, SWT.SEPARATOR);
-      }
-
-      menuItem = new MenuItem(parentMenu, SWT.PUSH);
-      initMenuItem(menuItem, guiMenuItem);
-
-      // Call the method to which the GuiWidgetElement annotation belongs.
-      //
-      menuItem.addListener(
-          SWT.Selection,
-          e -> {
-            try {
-              executeMenuItem(guiMenuItem, instanceId);
-            } catch (Exception ex) {
-              Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
-              String msg = cause.getMessage();
-              if (msg == null || msg.isEmpty()) {
-                msg = cause.getClass().getSimpleName();
-              }
-              LogChannel.UI.logError(
-                  "Unable to call method "
-                      + guiMenuItem.getListenerMethod()
-                      + " in singleton "
-                      + guiMenuItem.getListenerClassName()
-                      + " : "
-                      + msg,
-                  ex);
-            }
-          });
-
-      menuItemMap.put(guiMenuItem.getId(), menuItem);
-      menuEnabledMap.put(guiMenuItem.getId(), true);
+      addLeafMenuWidget(parentMenu, guiMenuItem, parentMenu.getItemCount());
 
     } else {
       // We have a bunch of children, so we want to create a new drop-down menu in the parent menu
@@ -212,6 +185,48 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
     }
   }
 
+  private void addLeafMenuWidget(
+      Menu parentMenu, GuiMenuItem guiMenuItem, int requestedInsertionIndex) {
+    int insertionIndex = Math.max(0, Math.min(requestedInsertionIndex, parentMenu.getItemCount()));
+    if (guiMenuItem.isAddingSeparator()) {
+      MenuItem separator = new MenuItem(parentMenu, SWT.SEPARATOR, insertionIndex++);
+      menuSeparatorMap.put(guiMenuItem.getId(), separator);
+    }
+
+    MenuItem menuItem = new MenuItem(parentMenu, SWT.PUSH, insertionIndex);
+    initMenuItem(menuItem, guiMenuItem);
+    menuItem.setEnabled(menuEnabledMap.getOrDefault(guiMenuItem.getId(), true));
+
+    // Call the method to which the GuiWidgetElement annotation belongs.
+    //
+    menuItem.addListener(
+        SWT.Selection,
+        e -> {
+          try {
+            executeMenuItem(guiMenuItem, instanceId);
+          } catch (Exception ex) {
+            Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+            String msg = cause.getMessage();
+            if (msg == null || msg.isEmpty()) {
+              msg = cause.getClass().getSimpleName();
+            }
+            LogChannel.UI.logError(
+                "Unable to call method "
+                    + guiMenuItem.getListenerMethod()
+                    + " in singleton "
+                    + guiMenuItem.getListenerClassName()
+                    + " : "
+                    + msg,
+                ex);
+          }
+        });
+
+    menuItemMap.put(guiMenuItem.getId(), menuItem);
+    menuParentMap.put(guiMenuItem.getId(), parentMenu);
+    menuDefinitionMap.put(guiMenuItem.getId(), guiMenuItem);
+    menuEnabledMap.putIfAbsent(guiMenuItem.getId(), true);
+  }
+
   public static void executeMenuItem(GuiMenuItem guiMenuItem, String instanceId) throws Exception {
     Object parentObject =
         findGuiPluginInstance(
@@ -298,28 +313,72 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
     return menuItemMap.get(id);
   }
 
+  /** Find the menu item with the given ID and remove it together with its owned separator. */
+  public void removeMenuItem(String id) {
+    menuEnabledMap.put(id, false);
+    disposeMenuItem(id);
+  }
+
+  private void disposeMenuItem(String id) {
+    MenuItem menuItem = menuItemMap.remove(id);
+    MenuItem separator = menuSeparatorMap.remove(id);
+    if (separator != null && !separator.isDisposed()) {
+      separator.dispose();
+    }
+    if (menuItem != null && !menuItem.isDisposed()) {
+      menuItem.dispose();
+    }
+  }
+
   /**
-   * Find the menu item with the given ID and remove it from the menu, together with the separator
-   * that was created in front of it. Use this for menu items which simply don't apply to the
-   * environment we're running in.
+   * Show or hide a leaf menu item. RAP's SWT MenuItem does not expose setVisible, so hidden items
+   * are disposed and recreated from their registered definition when they become available again.
    *
-   * @param id The ID to look for
+   * @param id the menu item ID
+   * @param visible whether the item should be present in the menu
    */
-  public void removeMenuItem(String id) {
-    MenuItem menuItem = menuItemMap.remove(id);
-    menuEnabledMap.remove(id);
-    if (menuItem == null || menuItem.isDisposed()) {
+  public void setMenuItemVisible(String id, boolean visible) {
+    MenuItem menuItem = menuItemMap.get(id);
+    if (!visible) {
+      disposeMenuItem(id);
+      return;
+    }
+    if (menuItem != null && !menuItem.isDisposed()) {
+      return;
+    }
+
+    GuiMenuItem definition = menuDefinitionMap.get(id);
+    Menu parentMenu = menuParentMap.get(id);
+    if (definition == null || parentMenu == null || parentMenu.isDisposed()) {
       return;
     }
-    Menu parentMenu = menuItem.getParent();
-    int index = parentMenu.indexOf(menuItem);
-    if (index > 0) {
-      MenuItem previousItem = parentMenu.getItem(index - 1);
-      if ((previousItem.getStyle() & SWT.SEPARATOR) != 0) {
-        previousItem.dispose();
+    addLeafMenuWidget(parentMenu, definition, findInsertionIndex(parentMenu, definition));
+  }
+
+  private int findInsertionIndex(Menu parentMenu, GuiMenuItem definition) {
+    GuiMenuItem nextDefinition = null;
+    MenuItem nextMenuItem = null;
+    for (Map.Entry<String, MenuItem> entry : menuItemMap.entrySet()) {
+      MenuItem candidate = entry.getValue();
+      GuiMenuItem candidateDefinition = menuDefinitionMap.get(entry.getKey());
+      if (candidate == null
+          || candidate.isDisposed()
+          || candidate.getParent() != parentMenu
+          || candidateDefinition == null
+          || candidateDefinition.compareTo(definition) <= 0) {
+        continue;
       }
+      if (nextDefinition == null || candidateDefinition.compareTo(nextDefinition) < 0) {
+        nextDefinition = candidateDefinition;
+        nextMenuItem = candidate;
+      }
+    }
+    if (nextMenuItem == null) {
+      return parentMenu.getItemCount();
     }
-    menuItem.dispose();
+    MenuItem separator = menuSeparatorMap.get(nextDefinition.getId());
+    return parentMenu.indexOf(
+        separator != null && !separator.isDisposed() ? separator : nextMenuItem);
   }
 
   public KeyboardShortcut findKeyboardShortcut(String id) {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 3605a396c2..b19c9b3f45 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -95,6 +95,7 @@ import org.apache.hop.partition.PartitionSchema;
 import org.apache.hop.server.HopServerMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
 import org.apache.hop.ui.core.bus.HopGuiEventsHandler;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.HopDescribedVariablesDialog;
@@ -210,6 +211,20 @@ public class HopGui
   public static final String ID_MAIN_MENU_FILE_LOG_OFF = "10850-menu-file-log-off";
   public static final String ID_MAIN_MENU_FILE_EXIT = "10900-menu-file-exit";
 
+  public static final String ID_MAIN_MENU_FILE_USER_NEW = "15010-menu-file-user-new";
+  public static final String ID_MAIN_MENU_FILE_USER_OPEN = "15020-menu-file-user-open";
+  public static final String ID_MAIN_MENU_FILE_USER_SAVE = "15030-menu-file-user-save";
+  public static final String ID_MAIN_MENU_FILE_USER_SAVE_AS = "15040-menu-file-user-save-as";
+  public static final String ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG =
+      "15050-menu-file-user-export-svg";
+  public static final String ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT =
+      "15060-menu-file-user-export-project";
+  public static final String ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE =
+      "15070-menu-file-user-import-kettle-zip";
+
+  private static final String ID_MAIN_MENU_PROJECT_EXPORT_PLUGIN = "10055-menu-file-export-to-svg";
+  private static final String ID_MAIN_MENU_KETTLE_IMPORT_PLUGIN = "10060-menu-tools-import";
+
   public static final String ID_MAIN_MENU_EDIT_PARENT_ID = "20000-menu-edit";
   public static final String ID_MAIN_MENU_EDIT_UNDO = "20010-menu-edit-undo";
   public static final String ID_MAIN_MENU_EDIT_REDO = "20020-menu-edit-redo";
@@ -1256,8 +1271,15 @@ public class HopGui
 
     mainMenu = new Menu(shell, SWT.BAR);
     mainMenuWidgets.createMenuWidgets(ID_MAIN_MENU, shell, mainMenu);
+    updateWebUserFileMenuItems();
     mainMenuWidgets.ensureShortcutPluginInstancesRegistered();
 
+    eventsHandler.addEventListener(
+        getClass().getName() + "WebProjectMenuItems",
+        event -> updateWebUserFileMenuItems(),
+        HopGuiEvents.ProjectActivated.name(),
+        HopGuiEvents.ProjectDeactivated.name());
+
     if (EnvironmentUtils.getInstance().isWeb()) {
       mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_FILE_EXIT, false);
     } else if (areSessionControlsVisible()) {
@@ -2331,6 +2353,7 @@ public class HopGui
         fileType, handler, ID_MAIN_MENU_FILE_SAVE, IHopFileType.CAPABILITY_SAVE, changed);
     mainMenuWidgets.enableMenuItem(
         fileType, handler, ID_MAIN_MENU_FILE_SAVE_AS, IHopFileType.CAPABILITY_SAVE_AS);
+
     mainMenuWidgets.enableMenuItem(
         fileType, handler, ID_MAIN_MENU_FILE_EXPORT_TO_SVG, IHopFileType.CAPABILITY_EXPORT_TO_SVG);
     mainMenuWidgets.enableMenuItem(
@@ -2390,6 +2413,60 @@ public class HopGui
         HopSecurity.allows(Permission.FILE_CREATE) || HopSecurity.allows(Permission.METADATA_WRITE);
     mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_NEW, canCreate);
     mainToolbarWidgets.enableToolbarItem(ID_MAIN_TOOLBAR_NEW, canCreate);
+
+    updateWebUserFileMenuItems();
+  }
+
+  private void updateWebUserFileMenuItems() {
+    if (!EnvironmentUtils.getInstance().isWeb()) {
+      return;
+    }
+
+    boolean projectExportAvailable =
+        GuiRegistry.getInstance().findGuiMenuItem(ID_MAIN_MENU, ID_MAIN_MENU_PROJECT_EXPORT_PLUGIN)
+            != null;
+    boolean kettleImportAvailable =
+        GuiRegistry.getInstance().findGuiMenuItem(ID_MAIN_MENU, ID_MAIN_MENU_KETTLE_IMPORT_PLUGIN)
+            != null;
+
+    boolean showProjectExport =
+        HopWebUserFileMenuState.shouldShowProjectExport(
+            variables.resolve(Const.VAR_PROJECT_HOME),
+            projectExportAvailable,
+            HopSecurity.allows(Permission.FILE_EXPORT));
+    boolean showKettleImport =
+        HopWebUserFileMenuState.shouldShowKettleImport(
+            kettleImportAvailable,
+            HopSecurity.allows(Permission.FILE_CREATE),
+            HopSecurity.allows(Permission.METADATA_WRITE));
+    boolean showSvgExport =
+        HopWebUserFileMenuState.shouldShowSvgExport(
+            getActivePipelineGraph() != null,
+            getActiveWorkflowGraph() != null,
+            HopSecurity.allows(Permission.FILE_EXPORT));
+
+    boolean canDownload =
+        HopWebUserFileMenuState.canDownload(
+            getActiveFileTypeHandler(), HopSecurity.allows(Permission.FILE_SAVE));
+    mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_SAVE, canDownload);
+    mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_SAVE_AS, canDownload);
+
+    // Keep the enabled-state map in sync for keyboard/menu dispatch and hide unavailable actions.
+    // File Server keeps the same actions, but shares the web-only visibility rules for SVG.
+    mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_EXPORT_TO_SVG, showSvgExport);
+    mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_EXPORT_TO_SVG, showSvgExport);
+    mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG, showSvgExport);
+    mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG, showSvgExport);
+    mainMenuWidgets.enableMenuItem(
+        ID_MAIN_MENU_FILE_USER_NEW,
+        HopSecurity.allows(Permission.FILE_CREATE)
+            || HopSecurity.allows(Permission.METADATA_WRITE));
+    mainMenuWidgets.enableMenuItem(
+        ID_MAIN_MENU_FILE_USER_OPEN, HopSecurity.allows(Permission.FILE_VIEW));
+    mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT, showProjectExport);
+    mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE, showKettleImport);
+    mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT, showProjectExport);
+    mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE, showKettleImport);
   }
 
   public IHopFileTypeHandler getActiveFileTypeHandler() {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFileMenuState.java b/ui/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFileMenuState.java
new file mode 100644
index 0000000000..31fe51d64a
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFileMenuState.java
@@ -0,0 +1,55 @@
+/*
+ * 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;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.workflow.WorkflowMeta;
+
+final class HopWebUserFileMenuState {
+
+  private HopWebUserFileMenuState() {}
+
+  static boolean canDownload(IHopFileTypeHandler handler, boolean saveAllowed) {
+    return saveAllowed
+        && handler != null
+        && (handler.getSubject() instanceof PipelineMeta
+            || handler.getSubject() instanceof WorkflowMeta);
+  }
+
+  static boolean shouldShowProjectExport(
+      String projectHome, boolean pluginAvailable, boolean exportAllowed) {
+    return StringUtils.isNotBlank(projectHome)
+        && !Const.VAR_PROJECT_HOME.equals(projectHome)
+        && pluginAvailable
+        && exportAllowed;
+  }
+
+  static boolean shouldShowKettleImport(
+      boolean pluginAvailable, boolean fileCreateAllowed, boolean metadataWriteAllowed) {
+    return pluginAvailable && fileCreateAllowed && metadataWriteAllowed;
+  }
+
+  static boolean shouldShowSvgExport(
+      boolean pipelineOpen, boolean workflowOpen, boolean exportAllowed) {
+    return (pipelineOpen || workflowOpen) && exportAllowed;
+  }
+}
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 0fc981b990..61d0d6a4ed 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
@@ -55,6 +55,7 @@ import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.file.HopFileTypeRegistry;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyFileType;
 import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
 import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
 import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
@@ -309,30 +310,40 @@ public class HopGuiFileDelegate {
 
   public void closeAllFiles() {
     this.isClosing = true;
-    for (IHopPerspective perspective : hopGui.getPerspectiveManager().getPerspectives()) {
-      List<TabItemHandler> tabItemHandlers = perspective.getItems();
-      if (tabItemHandlers != null) {
-        // Copy the list to avoid changing the list we're editing (closing items)
-        //
-        List<TabItemHandler> handlers = new ArrayList<>(tabItemHandlers);
-        for (TabItemHandler tabItemHandler : handlers) {
-          IHopFileTypeHandler typeHandler = tabItemHandler.getTypeHandler();
-          typeHandler.close();
+    try {
+      for (IHopPerspective perspective : hopGui.getPerspectiveManager().getPerspectives()) {
+        List<TabItemHandler> tabItemHandlers = perspective.getItems();
+        if (tabItemHandlers != null) {
+          // Copy the list to avoid changing the list we're editing (closing items)
+          //
+          List<TabItemHandler> handlers = new ArrayList<>(tabItemHandlers);
+          for (TabItemHandler tabItemHandler : handlers) {
+            IHopFileTypeHandler typeHandler = tabItemHandler.getTypeHandler();
+            typeHandler.close();
+          }
         }
       }
-    }
 
-    // Execution Information tabs are not IHopFileTypeHandlers, so they never appear in
-    // getItems(). Close them explicitly so project switches (and File → Close All) do not leave
-    // viewers from the previous project open. Callers that need to remember tabs (project switch)
-    // must call ExecutionPerspective.saveState() first.
-    //
-    ExecutionPerspective executionPerspective = ExecutionPerspective.getInstance();
-    if (executionPerspective != null) {
-      executionPerspective.closeAllTabs();
-    }
+      // Execution Information tabs are not IHopFileTypeHandlers, so they never appear in
+      // getItems(). Close them explicitly so project switches (and File → Close All) do not leave
+      // viewers from the previous project open. Callers that need to remember tabs (project switch)
+      // must call ExecutionPerspective.saveState() first.
+      //
+      ExecutionPerspective executionPerspective = ExecutionPerspective.getInstance();
+      if (executionPerspective != null) {
+        executionPerspective.closeAllTabs();
+      }
+    } finally {
+      this.isClosing = false;
 
-    this.isClosing = false;
+      IHopFileTypeHandler activeHandler = hopGui.getActiveFileTypeHandler();
+      if (activeHandler == null) {
+        hopGui.handleFileCapabilities(new EmptyFileType(), false, false, false);
+      } else {
+        hopGui.handleFileCapabilities(
+            activeHandler.getFileType(), activeHandler, activeHandler.hasChanged(), false, false);
+      }
+    }
   }
 
   /** When the app exits we need to see if all open files are saved in all perspectives... */
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 8d474ca9e9..c36babb2f9 100644
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -96,6 +96,19 @@ HopGui.Menu.File=&File
 HopGui.Menu.File.Close=&Close
 HopGui.Menu.File.Close.All=Close All
 HopGui.Menu.File.ExportToSVG=Export to SVG
+HopGui.Menu.File.ExportProjectZip=Export Project ZIP
+HopGui.Menu.File.ImportKettleZip=Import Kettle/PDI ZIP
+HopGui.Menu.File.User=File Browser
+HopGui.FileBrowser.Error.Title=File Browser
+HopGui.FileBrowser.Error.Upload=Unable to upload the selected file. Verify its type and size and try again.
+HopGui.FileBrowser.Error.Open=Unable to open the selected Hop pipeline or workflow.
+HopGui.FileBrowser.Error.Save=Unable to download the active file.
+HopGui.FileBrowser.Error.NoActiveFile=Select a pipeline or workflow before downloading it.
+HopGui.FileBrowser.Error.ExportSvg=Unable to export the active pipeline or workflow to SVG.
+HopGui.FileBrowser.Error.ExportProject=Unable to export the current project to the browser.
+HopGui.FileBrowser.Error.ImportKettle=Unable to import the selected Kettle/PDI ZIP file. Verify the archive and try again.
+HopGui.FileBrowser.SaveAs.Title=Download As
+HopGui.FileBrowser.SaveAs.Label=File name
 HopGui.Menu.File.New=&New
 HopGui.Menu.File.Open=&Open...
 HopGui.Menu.File.Open.Recent=Open Recent...
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties
index 5ce0ad92e6..f0a9ffd3fd 100644
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties
+++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties
@@ -97,6 +97,19 @@ HopGui.Menu.File=&Fichero
 HopGui.Menu.File.Close=&Cerrar
 HopGui.Menu.File.Close.All=Cerrar Todo
 HopGui.Menu.File.ExportToSVG=Exportar a SVG
+HopGui.Menu.File.ExportProjectZip=Exportar proyecto ZIP
+HopGui.Menu.File.ImportKettleZip=Importar ZIP de Kettle/PDI
+HopGui.Menu.File.User=File Browser
+HopGui.FileBrowser.Error.Title=Navegador de archivos
+HopGui.FileBrowser.Error.Upload=No se pudo subir el archivo seleccionado. Verifique su tipo y tamaño e inténtelo de nuevo.
+HopGui.FileBrowser.Error.Open=No se pudo abrir el pipeline o workflow de Hop seleccionado.
+HopGui.FileBrowser.Error.Save=No se pudo descargar el archivo activo.
+HopGui.FileBrowser.Error.NoActiveFile=Seleccione un pipeline o workflow antes de descargarlo.
+HopGui.FileBrowser.Error.ExportSvg=No se pudo exportar el pipeline o workflow activo a SVG.
+HopGui.FileBrowser.Error.ExportProject=No se pudo exportar el proyecto actual al navegador.
+HopGui.FileBrowser.Error.ImportKettle=No se pudo importar el ZIP de Kettle/PDI seleccionado. Verifique el archivo e inténtelo de nuevo.
+HopGui.FileBrowser.SaveAs.Title=Descargar como
+HopGui.FileBrowser.SaveAs.Label=Nombre del archivo
 HopGui.Menu.File.New=&Nuevo
 HopGui.Menu.File.Open=&Abrir
 HopGui.Menu.File.Open.Recent=Abrir reciente...
diff --git a/plugins/misc/import/src/main/resources/kettle-logo.svg b/ui/src/main/resources/ui/images/kettle-logo.svg
similarity index 54%
rename from plugins/misc/import/src/main/resources/kettle-logo.svg
rename to ui/src/main/resources/ui/images/kettle-logo.svg
index 22f3ed2e05..3fa73ab55b 100644
--- a/plugins/misc/import/src/main/resources/kettle-logo.svg
+++ b/ui/src/main/resources/ui/images/kettle-logo.svg
@@ -1,10 +1,26 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
-     viewBox="0 0 16 16" enable-background="new 0 0 16 16">
-    <rect fill="none" width="16" height="16"/>
-    <rect fill="#005CA7" class="cls-1" width="16" height="16" rx="2" ry="2"/>
-    <path fill="#FEFEFE"
-          d="M13,8.11a.94.94,0,0,0-.82.48L8.83,8s0-.07,0-.11a1.68,1.68,0,0,0-.11-.59L12,4.95a1.13,1.13,0,1,0-.36-.51L8.41,6.77a1.69,1.69,0,0,0-.64-.45l.19-1.08a1,1,0,1,0-.62-.06l-.18,1A1.68,1.68,0,0,0,6,6.62L4.43,5.13A1.25,1.25,0,1,0,4,5.59L5.65,7.11a1.69,1.69,0,0,0,0,1.54l-1,.71A.94.94,0,1,0,5,9.88L6,9.15a1.69,1.69,0,0,0,1.64.35L8.81,11a1.26,1.26,0,1,0,.51-.37L8.22,9.2a1.69,1.69,0,0,0,.46-.59l3.38.59A.94.94,0,1,0,13,8.11Z"/>
-</svg>
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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.
+-->
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
+     viewBox="0 0 16 16" enable-background="new 0 0 16 16">
+    <rect fill="none" width="16" height="16"/>
+    <rect fill="#005CA7" class="cls-1" width="16" height="16" rx="2" ry="2"/>
+    <path fill="#FEFEFE"
+          d="M13,8.11a.94.94,0,0,0-.82.48L8.83,8s0-.07,0-.11a1.68,1.68,0,0,0-.11-.59L12,4.95a1.13,1.13,0,1,0-.36-.51L8.41,6.77a1.69,1.69,0,0,0-.64-.45l.19-1.08a1,1,0,1,0-.62-.06l-.18,1A1.68,1.68,0,0,0,6,6.62L4.43,5.13A1.25,1.25,0,1,0,4,5.59L5.65,7.11a1.69,1.69,0,0,0,0,1.54l-1,.71A.94.94,0,1,0,5,9.88L6,9.15a1.69,1.69,0,0,0,1.64.35L8.81,11a1.26,1.26,0,1,0,.51-.37L8.22,9.2a1.69,1.69,0,0,0,.46-.59l3.38.59A.94.94,0,1,0,13,8.11Z"/>
+</svg>
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiWebUserFileMenuTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiWebUserFileMenuTest.java
new file mode 100644
index 0000000000..072d67718c
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiWebUserFileMenuTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.Test;
+
+class HopGuiWebUserFileMenuTest {
+
+  @Test
+  void browserSaveAllowsUnchangedFlowsButRejectsOtherFileTypesAndDeniedPermission() {
+    IHopFileTypeHandler handler = mock(IHopFileTypeHandler.class);
+    when(handler.hasChanged()).thenReturn(false);
+    when(handler.getSubject()).thenReturn(new PipelineMeta());
+    assertTrue(HopWebUserFileMenuState.canDownload(handler, true));
+    when(handler.getSubject()).thenReturn(new WorkflowMeta());
+    assertTrue(HopWebUserFileMenuState.canDownload(handler, true));
+    assertFalse(HopWebUserFileMenuState.canDownload(handler, false));
+    when(handler.getSubject()).thenReturn("text editor");
+    assertFalse(HopWebUserFileMenuState.canDownload(handler, true));
+    assertFalse(HopWebUserFileMenuState.canDownload(null, true));
+  }
+
+  @Test
+  void projectExportRequiresAnActiveProjectPluginAndPermission() {
+    assertFalse(HopWebUserFileMenuState.shouldShowProjectExport(null, true, true));
+    assertFalse(HopWebUserFileMenuState.shouldShowProjectExport("", true, true));
+    assertFalse(
+        HopWebUserFileMenuState.shouldShowProjectExport(Const.VAR_PROJECT_HOME, true, true));
+    assertFalse(HopWebUserFileMenuState.shouldShowProjectExport("project", false, true));
+    assertFalse(HopWebUserFileMenuState.shouldShowProjectExport("project", true, false));
+    assertTrue(HopWebUserFileMenuState.shouldShowProjectExport("project", true, true));
+  }
+
+  @Test
+  void kettleImportRequiresThePluginAndWritePermissions() {
+    assertFalse(HopWebUserFileMenuState.shouldShowKettleImport(false, true, true));
+    assertFalse(HopWebUserFileMenuState.shouldShowKettleImport(true, false, true));
+    assertFalse(HopWebUserFileMenuState.shouldShowKettleImport(true, true, false));
+    assertTrue(HopWebUserFileMenuState.shouldShowKettleImport(true, true, true));
+  }
+
+  @Test
+  void svgExportRequiresAnOpenFlowAndExportPermission() {
+    assertFalse(HopWebUserFileMenuState.shouldShowSvgExport(false, false, true));
+    assertFalse(HopWebUserFileMenuState.shouldShowSvgExport(true, false, false));
+    assertFalse(HopWebUserFileMenuState.shouldShowSvgExport(false, true, false));
+    assertTrue(HopWebUserFileMenuState.shouldShowSvgExport(true, false, true));
+    assertTrue(HopWebUserFileMenuState.shouldShowSvgExport(false, true, true));
+  }
+}