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

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 394947fbcb issues #7585, #7586, #7116 : Projects improvements (#7588)
394947fbcb is described below

commit 394947fbcb88981a494646ae03e8fb5cd37ece38
Author: Matt Casters <[email protected]>
AuthorDate: Tue Jul 21 11:02:10 2026 +0200

    issues #7585, #7586, #7116 : Projects improvements (#7588)
    
    * issue #7585 : Open File dialog browse inside archives (zip/jar/tar)
    
    Add a Drill into toolbar button on the VFS file browser so users can
    open supported Commons VFS archives without hand-editing URIs.
    
    * issue #7586: read-only projects and as-is zip export (#7585)
    
    Add a GUI option to open projects in read-only mode (no project-config.json
    writes), auto-enabled for archive URIs, with validation that the config file
    exists. Export can write projects as-is to the zip root for that workflow.
    
    Also extend VFS directory/file browsing so archives appear when choosing a
    folder, double-click drills into zip/jar/tar, and OK still selects the 
archive
    as a file. Fixes FileTree checkbox cascade so export selection works.
    
    Closes #7586
    Closes #7585
    
    * Issue #7116 : improve project organization in Apache Hop Project Manager
---
 .../modules/ROOT/assets/images/icons/zipfile.svg   |  21 +
 .../modules/ROOT/pages/hop-gui/file-dialog.adoc    |   1 +
 .../apache/hop/projects/config/ProjectsConfig.java |  45 ++
 .../hop/projects/gui/ProjectConfigDialog.java      | 305 ++++++++++++++
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java | 460 +++++++++++++--------
 .../hop/projects/gui/SelectProjectsDialog.java     | 394 ++++++++++++++++++
 .../apache/hop/projects/project/ProjectConfig.java | 171 ++++++++
 .../apache/hop/projects/project/ProjectDialog.java | 108 ++++-
 .../gui/messages/messages_en_US.properties         |  32 ++
 .../project/messages/messages_en_US.properties     |   3 +
 .../hop/projects/gui/ProjectsGuiPluginZipTest.java |  53 +++
 .../apache/hop/projects/project/ProjectTest.java   | 135 ++++++
 .../apache/hop/ui/core/vfs/HopVfsFileDialog.java   | 221 ++++++++--
 .../org/apache/hop/ui/core/widget/FileTree.java    | 136 ++++--
 .../ui/core/vfs/messages/messages_en_US.properties |   2 +
 .../ui/core/vfs/HopVfsFileDialogArchiveTest.java   | 101 +++++
 16 files changed, 1929 insertions(+), 259 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/icons/zipfile.svg 
b/docs/hop-user-manual/modules/ROOT/assets/images/icons/zipfile.svg
new file mode 100644
index 0000000000..ed483c82c6
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/assets/images/icons/zipfile.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="42" height="42">
+<g>
+       <path fill="#FFECCE" 
d="M27.858,2.565H12.894v2.252h-1.813V2.565h-3.43v36.867h26.632V8.997h-6.426V2.565z
 M11.08,8.479h1.813
+               v3.548H11.08V8.479z M11.08,15.689h1.813v3.548H11.08V15.689z 
M11.08,22.879h1.813v3.546H11.08V22.879z M14.871,35.685h-3.933
+               v-8.365h3.933V35.685z M14.98,22.842h-1.812v-3.547h1.812V22.842z 
M14.98,15.632h-1.812v-3.548h1.812V15.632z M14.98,8.422h-1.812
+               V4.875h1.812V8.422z"/>
+    <path fill="#FFECCE" 
d="M12.058,34.907h1.694c0.275,0,0.495-0.22,0.495-0.494v-0.764c0-0.274-0.22-0.495-0.495-0.495h-1.694
+               
c-0.273,0-0.495,0.221-0.495,0.495v0.764C11.563,34.685,11.786,34.907,12.058,34.907z"/>
+    <path fill="#FF9C04" 
d="M34.271,6.429h-3.847V2.583L27.843,0H5.085v42h31.767V9.008L34.271,6.429z 
M34.284,39.433H7.652V2.565h3.43
+               v2.252h1.813V2.565h14.964v6.431h6.426V39.433z"/>
+    <rect x="11.08" y="8.479" fill="#FF9C04" width="1.813" height="3.548"/>
+    <rect x="11.08" y="15.689" fill="#FF9C04" width="1.813" height="3.548"/>
+    <rect x="13.168" y="4.875" fill="#FF9C04" width="1.812" height="3.547"/>
+    <rect x="13.168" y="12.085" fill="#FF9C04" width="1.812" height="3.549"/>
+    <rect x="13.168" y="19.294" fill="#FF9C04" width="1.812" height="3.547"/>
+    <rect x="11.08" y="22.879" fill="#FF9C04" width="1.813" height="3.546"/>
+    <path fill="#FF9C04" d="M10.938,35.685h3.933v-8.365h-3.933V35.685z 
M11.563,33.649c0-0.274,0.222-0.495,0.495-0.495h1.694
+               
c0.275,0,0.495,0.221,0.495,0.495v0.764c0,0.274-0.22,0.494-0.495,0.494h-1.694c-0.273,0-0.495-0.22-0.495-0.494V33.649z"/>
+</g>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
index 6b6b611eea..0456b1635c 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/file-dialog.adoc
@@ -62,6 +62,7 @@ Tools -> Options contains an option to specify whether you 
want or don't want gl
 .. image:icons/delete.svg[width="25px"]Remove the selected bookmark
 . **File/folder browser**: Browse your current file system, create folders and 
toggle hidden files and folders.
 .. image:icons/folder-add.svg[width="25px"]Create folder
+.. image:icons/zipfile.svg[width="25px"]Drill into a selected archive (zip, 
jar, tar, tgz, tbz2) using VFS. Double-clicking an archive does the same; use 
*OK* if you need to select the archive file itself. Directory browsers also 
list these archives so you can open a folder inside a zip (for example a 
project home).
 .. image:icons/preview.svg[width="25px"]Show or hide hidden files and 
directories
 . **File/folder information**
 .. file and folder name
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
index 5e538c83d3..0f0cf6c6bc 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
@@ -117,9 +117,54 @@ public class ProjectsConfig {
       existing.setProjectName(projectConfig.getProjectName());
       existing.setProjectHome(projectConfig.getProjectHome());
       existing.setConfigFilename(projectConfig.getConfigFilename());
+      existing.setReadOnly(projectConfig.isReadOnly());
+      existing.setGroup(projectConfig.getGroup());
+      existing.setTags(projectConfig.getTags());
     }
   }
 
+  /**
+   * Update an existing project registration, including rename of the project 
name.
+   *
+   * @param originalName name currently stored in hop-config
+   * @param projectConfig updated registration (may have a new projectName)
+   */
+  public void updateProjectConfig(String originalName, ProjectConfig 
projectConfig) {
+    if (StringUtils.isEmpty(originalName)
+        || originalName.equalsIgnoreCase(projectConfig.getProjectName())) {
+      addProjectConfig(projectConfig);
+      return;
+    }
+    ProjectConfig existing = findProjectConfig(originalName);
+    if (existing == null) {
+      addProjectConfig(projectConfig);
+      return;
+    }
+    existing.setProjectName(projectConfig.getProjectName());
+    existing.setProjectHome(projectConfig.getProjectHome());
+    existing.setConfigFilename(projectConfig.getConfigFilename());
+    existing.setReadOnly(projectConfig.isReadOnly());
+    existing.setGroup(projectConfig.getGroup());
+    existing.setTags(projectConfig.getTags());
+  }
+
+  /**
+   * Distinct non-empty group names from registered projects, sorted 
case-insensitively.
+   *
+   * @return list of group names
+   */
+  public List<String> listProjectGroups() {
+    List<String> groups = new ArrayList<>();
+    for (ProjectConfig projectConfig : projectConfigurations) {
+      String group = projectConfig.getGroup();
+      if (StringUtils.isNotEmpty(group) && !groups.contains(group)) {
+        groups.add(group);
+      }
+    }
+    Collections.sort(groups, String.CASE_INSENSITIVE_ORDER);
+    return groups;
+  }
+
   public int indexOfProjectConfig(String projectName) {
     return projectConfigurations.indexOf(
         new ProjectConfig(projectName, null, null)); // Only considers the name
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
new file mode 100644
index 0000000000..e60739e903
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
@@ -0,0 +1,305 @@
+/*
+ * 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.projects.gui;
+
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.ComboVar;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Small dialog to edit a project <em>registration</em> ({@link ProjectConfig} 
in hop-config.json):
+ * name, home, config path, group, tags, and read-only. Does not write 
project-config.json.
+ */
+public class ProjectConfigDialog extends Dialog {
+  private static final Class<?> PKG = ProjectConfigDialog.class;
+
+  private final PropsUi props;
+  private final IVariables variables;
+  private final ProjectConfig projectConfig;
+  private final String originalName;
+
+  private Shell shell;
+  private Text wName;
+  private TextVar wHome;
+  private TextVar wConfigFile;
+  private ComboVar wGroup;
+  private Text wTags;
+  private Button wReadOnly;
+
+  private boolean ok;
+
+  public ProjectConfigDialog(Shell parent, ProjectConfig projectConfig, 
IVariables variables) {
+    super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
+    this.projectConfig = projectConfig;
+    this.variables = variables;
+    this.originalName = projectConfig.getProjectName();
+    this.props = PropsUi.getInstance();
+  }
+
+  /**
+   * Open the dialog.
+   *
+   * @return true if the user confirmed changes
+   */
+  public boolean open() {
+    Shell parent = getParent();
+    shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | 
SWT.RESIZE);
+    shell.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Shell.Name"));
+    shell.setImage(
+        GuiResource.getInstance()
+            .getImage(
+                "project.svg",
+                PKG.getClassLoader(),
+                ConstUi.SMALL_ICON_SIZE,
+                ConstUi.SMALL_ICON_SIZE));
+    PropsUi.setLook(shell);
+
+    int margin = PropsUi.getMargin();
+    int middle = props.getMiddlePct();
+
+    FormLayout formLayout = new FormLayout();
+    formLayout.marginWidth = PropsUi.getFormMargin();
+    formLayout.marginHeight = PropsUi.getFormMargin();
+    shell.setLayout(formLayout);
+
+    Button wOk = new Button(shell, SWT.PUSH);
+    wOk.setText(BaseMessages.getString("System.Button.OK"));
+    wOk.addListener(SWT.Selection, e -> ok());
+    Button wCancel = new Button(shell, SWT.PUSH);
+    wCancel.setText(BaseMessages.getString("System.Button.Cancel"));
+    wCancel.addListener(SWT.Selection, e -> cancel());
+    BaseTransformDialog.positionBottomButtons(shell, new Button[] {wOk, 
wCancel}, margin * 3, null);
+
+    Label wlName = new Label(shell, SWT.RIGHT);
+    PropsUi.setLook(wlName);
+    wlName.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Name"));
+    FormData fdlName = new FormData();
+    fdlName.left = new FormAttachment(0, 0);
+    fdlName.right = new FormAttachment(middle, 0);
+    fdlName.top = new FormAttachment(0, margin * 2);
+    wlName.setLayoutData(fdlName);
+    wName = new Text(shell, SWT.SINGLE | SWT.BORDER);
+    PropsUi.setLook(wName);
+    FormData fdName = new FormData();
+    fdName.left = new FormAttachment(middle, margin);
+    fdName.right = new FormAttachment(100, 0);
+    fdName.top = new FormAttachment(wlName, 0, SWT.CENTER);
+    wName.setLayoutData(fdName);
+    Control lastControl = wName;
+
+    Label wlHome = new Label(shell, SWT.RIGHT);
+    PropsUi.setLook(wlHome);
+    wlHome.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Home"));
+    FormData fdlHome = new FormData();
+    fdlHome.left = new FormAttachment(0, 0);
+    fdlHome.right = new FormAttachment(middle, 0);
+    fdlHome.top = new FormAttachment(lastControl, margin);
+    wlHome.setLayoutData(fdlHome);
+    Button wbHome = new Button(shell, SWT.PUSH);
+    PropsUi.setLook(wbHome);
+    wbHome.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Button.Browse"));
+    FormData fdbHome = new FormData();
+    fdbHome.right = new FormAttachment(100, 0);
+    fdbHome.top = new FormAttachment(wlHome, 0, SWT.CENTER);
+    wbHome.setLayoutData(fdbHome);
+    wbHome.addListener(
+        SWT.Selection, e -> BaseDialog.presentDirectoryDialog(shell, wHome, 
variables));
+    wHome = new TextVar(variables, shell, SWT.SINGLE | SWT.BORDER);
+    PropsUi.setLook(wHome);
+    FormData fdHome = new FormData();
+    fdHome.left = new FormAttachment(middle, margin);
+    fdHome.right = new FormAttachment(wbHome, -margin);
+    fdHome.top = new FormAttachment(wlHome, 0, SWT.CENTER);
+    wHome.setLayoutData(fdHome);
+    lastControl = wHome;
+
+    Label wlConfigFile = new Label(shell, SWT.RIGHT);
+    PropsUi.setLook(wlConfigFile);
+    wlConfigFile.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.ConfigFile"));
+    FormData fdlConfigFile = new FormData();
+    fdlConfigFile.left = new FormAttachment(0, 0);
+    fdlConfigFile.right = new FormAttachment(middle, 0);
+    fdlConfigFile.top = new FormAttachment(lastControl, margin);
+    wlConfigFile.setLayoutData(fdlConfigFile);
+    wConfigFile = new TextVar(variables, shell, SWT.SINGLE | SWT.BORDER);
+    PropsUi.setLook(wConfigFile);
+    FormData fdConfigFile = new FormData();
+    fdConfigFile.left = new FormAttachment(middle, margin);
+    fdConfigFile.right = new FormAttachment(100, 0);
+    fdConfigFile.top = new FormAttachment(wlConfigFile, 0, SWT.CENTER);
+    wConfigFile.setLayoutData(fdConfigFile);
+    lastControl = wConfigFile;
+
+    Label wlGroup = new Label(shell, SWT.RIGHT);
+    PropsUi.setLook(wlGroup);
+    wlGroup.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Group"));
+    FormData fdlGroup = new FormData();
+    fdlGroup.left = new FormAttachment(0, 0);
+    fdlGroup.right = new FormAttachment(middle, 0);
+    fdlGroup.top = new FormAttachment(lastControl, margin);
+    wlGroup.setLayoutData(fdlGroup);
+    wGroup = new ComboVar(variables, shell, SWT.SINGLE | SWT.BORDER | 
SWT.LEFT);
+    PropsUi.setLook(wGroup);
+    FormData fdGroup = new FormData();
+    fdGroup.left = new FormAttachment(middle, margin);
+    fdGroup.right = new FormAttachment(100, 0);
+    fdGroup.top = new FormAttachment(wlGroup, 0, SWT.CENTER);
+    wGroup.setLayoutData(fdGroup);
+    lastControl = wGroup;
+
+    Label wlTags = new Label(shell, SWT.RIGHT);
+    PropsUi.setLook(wlTags);
+    wlTags.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Tags"));
+    FormData fdlTags = new FormData();
+    fdlTags.left = new FormAttachment(0, 0);
+    fdlTags.right = new FormAttachment(middle, 0);
+    fdlTags.top = new FormAttachment(lastControl, margin);
+    wlTags.setLayoutData(fdlTags);
+    wTags = new Text(shell, SWT.SINGLE | SWT.BORDER);
+    PropsUi.setLook(wTags);
+    wTags.setToolTipText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.Tags.Tooltip"));
+    FormData fdTags = new FormData();
+    fdTags.left = new FormAttachment(middle, margin);
+    fdTags.right = new FormAttachment(100, 0);
+    fdTags.top = new FormAttachment(wlTags, 0, SWT.CENTER);
+    wTags.setLayoutData(fdTags);
+    lastControl = wTags;
+
+    Label wlReadOnly = new Label(shell, SWT.RIGHT);
+    PropsUi.setLook(wlReadOnly);
+    wlReadOnly.setText(BaseMessages.getString(PKG, 
"ProjectConfigDialog.Label.ReadOnly"));
+    FormData fdlReadOnly = new FormData();
+    fdlReadOnly.left = new FormAttachment(0, 0);
+    fdlReadOnly.right = new FormAttachment(middle, 0);
+    fdlReadOnly.top = new FormAttachment(lastControl, margin);
+    wlReadOnly.setLayoutData(fdlReadOnly);
+    wReadOnly = new Button(shell, SWT.CHECK | SWT.LEFT);
+    PropsUi.setLook(wReadOnly);
+    FormData fdReadOnly = new FormData();
+    fdReadOnly.left = new FormAttachment(middle, margin);
+    fdReadOnly.right = new FormAttachment(100, 0);
+    fdReadOnly.top = new FormAttachment(wlReadOnly, 0, SWT.CENTER);
+    wReadOnly.setLayoutData(fdReadOnly);
+
+    getData();
+
+    shell.setMinimumSize(500, 280);
+    shell.setDefaultButton(wOk);
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+    return ok;
+  }
+
+  private void getData() {
+    wName.setText(Const.NVL(projectConfig.getProjectName(), ""));
+    wHome.setText(Const.NVL(projectConfig.getProjectHome(), ""));
+    wConfigFile.setText(Const.NVL(projectConfig.getConfigFilename(), ""));
+    wGroup.setText(Const.NVL(projectConfig.getGroup(), ""));
+    List<String> groups = 
ProjectsConfigSingleton.getConfig().listProjectGroups();
+    wGroup.setItems(groups.toArray(new String[0]));
+    wTags.setText(projectConfig.getTagsAsDisplayString());
+    wReadOnly.setSelection(
+        projectConfig.isReadOnly()
+            || 
ProjectConfig.isArchiveUri(variables.resolve(projectConfig.getProjectHome())));
+  }
+
+  private void ok() {
+    try {
+      String name = wName.getText();
+      if (StringUtils.isEmpty(name)) {
+        throw new IllegalArgumentException(
+            BaseMessages.getString(PKG, 
"ProjectConfigDialog.Error.NameRequired"));
+      }
+      if (StringUtils.isEmpty(wHome.getText())) {
+        throw new IllegalArgumentException(
+            BaseMessages.getString(PKG, 
"ProjectConfigDialog.Error.HomeRequired"));
+      }
+      if (StringUtils.isEmpty(wConfigFile.getText())) {
+        throw new IllegalArgumentException(
+            BaseMessages.getString(PKG, 
"ProjectConfigDialog.Error.ConfigRequired"));
+      }
+
+      ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+      if (!name.equals(originalName)) {
+        ProjectConfig clash = config.findProjectConfig(name);
+        if (clash != null) {
+          throw new IllegalArgumentException(
+              BaseMessages.getString(PKG, 
"ProjectConfigDialog.Error.NameExists", name));
+        }
+      }
+
+      projectConfig.setProjectName(name);
+      projectConfig.setProjectHome(wHome.getText());
+      projectConfig.setConfigFilename(wConfigFile.getText());
+      projectConfig.setGroup(StringUtils.trimToEmpty(wGroup.getText()));
+      projectConfig.setTags(ProjectConfig.parseTags(wTags.getText()));
+      projectConfig.setReadOnly(wReadOnly.getSelection());
+
+      ok = true;
+      dispose();
+    } catch (Exception e) {
+      new ErrorDialog(
+          shell,
+          BaseMessages.getString(PKG, "ProjectConfigDialog.Error.Header"),
+          BaseMessages.getString(PKG, "ProjectConfigDialog.Error.Message"),
+          e);
+    }
+  }
+
+  private void cancel() {
+    ok = false;
+    dispose();
+  }
+
+  private void dispose() {
+    props.setScreen(new WindowProperty(shell));
+    shell.dispose();
+  }
+
+  /**
+   * Original project name when the dialog opened (for rename detection).
+   *
+   * @return original name
+   */
+  public String getOriginalName() {
+    return originalName;
+  }
+}
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 68001603d6..94aee6ce3f 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
@@ -77,7 +77,6 @@ import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.bus.HopGuiEvents;
 import org.apache.hop.ui.core.dialog.BaseDialog;
-import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.dialog.ProgressMonitorDialog;
@@ -582,28 +581,25 @@ public class ProjectsGuiPlugin {
         }
       }
 
-      String projectFolder = projectConfig.getProjectHome();
-
       ProjectDialog projectDialog =
           new ProjectDialog(
               hopGui.getActiveShell(), project, projectConfig, 
hopGui.getVariables(), true);
       if (projectDialog.open() != null) {
         config.addProjectConfig(projectConfig);
 
-        // Project's home changed. Update reference in hop-config.json
-        if (!projectFolder.equals(projectConfig.getProjectHome())) {
-          // Refresh project reference in hop-config.json
-          HopConfig.getInstance().saveToFile();
-        }
-
         if (!projectName.equals(projectConfig.getProjectName())) {
           // Project got renamed
           projectName = projectConfig.getProjectName();
-          // Refresh project reference in hop-config.json
-          HopConfig.getInstance().saveToFile();
         }
 
-        project.saveToFile();
+        // Persist project registration (name, home, config path, read-only) 
in hop-config.json
+        HopConfig.getInstance().saveToFile();
+
+        // Do not write project-config.json for read-only projects (archives, 
HTTP, ...).
+        //
+        if (!projectConfig.isReadOnly()) {
+          project.saveToFile();
+        }
         updateProjectToolItem(projectName);
 
         if (projectDialog.isNeedingProjectRefresh()) {
@@ -795,28 +791,9 @@ public class ProjectsGuiPlugin {
   }
 
   public void selectProject() {
-    List<String> projectNames = 
ProjectsConfigSingleton.getConfig().listProjectConfigNames();
-    projectNames.sort(String::compareToIgnoreCase);
-
-    String[] projects = projectNames.toArray(new String[0]);
-
-    EnterSelectionDialog dialog =
-        new EnterSelectionDialog(
-            HopGui.getInstance().getActiveShell(),
-            projects,
-            BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Dialog.AvailableProjects.Title"),
-            BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Dialog.AvailableProjects.Message"));
-
-    String currentProject = HopNamespace.getNamespace();
-    dialog.setCurrentValue(currentProject);
-    int index = Const.indexOfString(currentProject, projects);
-    if (index >= 0) {
-      dialog.setSelectedNrs(
-          new int[] {
-            index,
-          });
-    }
-
+    HopGui hopGui = HopGui.getInstance();
+    SelectProjectsDialog dialog =
+        new SelectProjectsDialog(hopGui.getActiveShell(), 
hopGui.getVariables());
     String name = dialog.open();
     if (name != null) {
       selectProject(name);
@@ -1015,15 +992,19 @@ public class ProjectsGuiPlugin {
         config.addProjectConfig(projectConfig);
         HopConfig.getInstance().saveToFile();
 
-        // Save the project-config.json file as well in the project itself
+        // Save or load the project-config.json in the project itself.
+        // Read-only projects (archives, HTTP, ...) never write the config 
file: it must already
+        // exist (validated in the project dialog).
         //
         FileObject configFile =
             
HopVfs.getFileObject(projectConfig.getActualProjectConfigFilename(variables));
-        if (!configFile.exists()) {
-          // Create the empty configuration file if it does not exists
+        if (projectConfig.isReadOnly()) {
+          project.readFromFile();
+        } else if (!configFile.exists()) {
+          // Create the empty configuration file if it does not exist
           project.saveToFile();
         } else {
-          // If projects exists load configuration
+          // If project already exists load configuration
           MessageBox box = new MessageBox(hopGui.getActiveShell(), 
SWT.ICON_QUESTION | SWT.OK);
           box.setText(BaseMessages.getString(PKG, 
"ProjectGuiPlugin.ProjectExists.Dialog.Header"));
           box.setMessage(
@@ -1037,77 +1018,83 @@ public class ProjectsGuiPlugin {
 
         enableHopGuiProject(projectName, project, null);
 
-        // Now see if these project contains any local run configurations.
-        // If not we can add those automatically
-        //
-        // First pipeline
+        // Do not create local run configurations inside a read-only project 
(would try to write
+        // metadata).
         //
-        IHopMetadataSerializer<PipelineRunConfiguration> prcSerializer =
-            
hopGui.getMetadataProvider().getSerializer(PipelineRunConfiguration.class);
-        List<PipelineRunConfiguration> pipelineRunConfigs = 
prcSerializer.loadAll();
-        boolean localFound = false;
-        for (PipelineRunConfiguration pipelineRunConfig : pipelineRunConfigs) {
-          if (pipelineRunConfig.getEngineRunConfiguration()
-              instanceof LocalPipelineRunConfiguration) {
-            localFound = true;
-            break;
+        if (!projectConfig.isReadOnly()) {
+          // Now see if these project contains any local run configurations.
+          // If not we can add those automatically
+          //
+          // First pipeline
+          //
+          IHopMetadataSerializer<PipelineRunConfiguration> prcSerializer =
+              
hopGui.getMetadataProvider().getSerializer(PipelineRunConfiguration.class);
+          List<PipelineRunConfiguration> pipelineRunConfigs = 
prcSerializer.loadAll();
+          boolean localFound = false;
+          for (PipelineRunConfiguration pipelineRunConfig : 
pipelineRunConfigs) {
+            if (pipelineRunConfig.getEngineRunConfiguration()
+                instanceof LocalPipelineRunConfiguration) {
+              localFound = true;
+              break;
+            }
+          }
+          if (!localFound) {
+            
PipelineExecutionConfigurationDialog.createLocalPipelineConfiguration(
+                hopGui.getShell(), prcSerializer);
           }
-        }
-        if (!localFound) {
-          
PipelineExecutionConfigurationDialog.createLocalPipelineConfiguration(
-              hopGui.getShell(), prcSerializer);
-        }
 
-        // Then the local workflow run configuration
-        //
-        IHopMetadataSerializer<WorkflowRunConfiguration> wrcSerializer =
-            
hopGui.getMetadataProvider().getSerializer(WorkflowRunConfiguration.class);
-        localFound = false;
-        List<WorkflowRunConfiguration> workflowRunConfigs = 
wrcSerializer.loadAll();
-        for (WorkflowRunConfiguration workflowRunConfig : workflowRunConfigs) {
-          if (workflowRunConfig.getEngineRunConfiguration()
-              instanceof LocalWorkflowRunConfiguration) {
-            localFound = true;
-            break;
+          // Then the local workflow run configuration
+          //
+          IHopMetadataSerializer<WorkflowRunConfiguration> wrcSerializer =
+              
hopGui.getMetadataProvider().getSerializer(WorkflowRunConfiguration.class);
+          localFound = false;
+          List<WorkflowRunConfiguration> workflowRunConfigs = 
wrcSerializer.loadAll();
+          for (WorkflowRunConfiguration workflowRunConfig : 
workflowRunConfigs) {
+            if (workflowRunConfig.getEngineRunConfiguration()
+                instanceof LocalWorkflowRunConfiguration) {
+              localFound = true;
+              break;
+            }
           }
-        }
-        if (!localFound) {
+          if (!localFound) {
+            MessageBox box =
+                new MessageBox(
+                    HopGui.getInstance().getShell(), SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+            box.setText(
+                BaseMessages.getString(PKG, 
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Header"));
+            box.setMessage(
+                BaseMessages.getString(PKG, 
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Message"));
+            int answer = box.open();
+            if ((answer & SWT.YES) != 0) {
+              LocalWorkflowRunConfiguration localWorkflowRunConfiguration =
+                  new LocalWorkflowRunConfiguration();
+              localWorkflowRunConfiguration.setEnginePluginId("Local");
+              WorkflowRunConfiguration local =
+                  new WorkflowRunConfiguration(
+                      "local",
+                      BaseMessages.getString(
+                          PKG, 
"ProjectGuiPlugin.LocalWFRunConfigDescription.Text"),
+                      null,
+                      localWorkflowRunConfiguration,
+                      true);
+              wrcSerializer.save(local);
+            }
+          }
+
+          // Ask to put the project in a lifecycle environment
+          //
           MessageBox box =
               new MessageBox(HopGui.getInstance().getShell(), SWT.YES | SWT.NO 
| SWT.ICON_QUESTION);
-          box.setText(
-              BaseMessages.getString(PKG, 
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Header"));
+          box.setText(BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Lifecycle.Dialog.Header"));
           box.setMessage(
-              BaseMessages.getString(PKG, 
"ProjectGuiPlugin.LocalWFRunConfig.Dialog.Message"));
+              BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Lifecycle.Dialog.Message1")
+                  + Const.CR
+                  + BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Lifecycle.Dialog.Message2"));
           int answer = box.open();
           if ((answer & SWT.YES) != 0) {
-            LocalWorkflowRunConfiguration localWorkflowRunConfiguration =
-                new LocalWorkflowRunConfiguration();
-            localWorkflowRunConfiguration.setEnginePluginId("Local");
-            WorkflowRunConfiguration local =
-                new WorkflowRunConfiguration(
-                    "local",
-                    BaseMessages.getString(
-                        PKG, 
"ProjectGuiPlugin.LocalWFRunConfigDescription.Text"),
-                    null,
-                    localWorkflowRunConfiguration,
-                    true);
-            wrcSerializer.save(local);
-          }
-        }
-
-        // Ask to put the project in a lifecycle environment
-        //
-        MessageBox box =
-            new MessageBox(HopGui.getInstance().getShell(), SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
-        box.setText(BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Lifecycle.Dialog.Header"));
-        box.setMessage(
-            BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Lifecycle.Dialog.Message1")
-                + Const.CR
-                + BaseMessages.getString(PKG, 
"ProjectGuiPlugin.Lifecycle.Dialog.Message2"));
-        int answer = box.open();
-        if ((answer & SWT.YES) != 0) {
 
-          addNewEnvironment();
+            addNewEnvironment();
+          }
         }
       }
 
@@ -1131,29 +1118,41 @@ public class ProjectsGuiPlugin {
     // Use getToolbarItemText() for RAP compatibility (text is stored in 
Label, not ToolItem)
     String projectName =
         
HopGui.getInstance().getStatusToolbarWidgets().getToolbarItemText(ID_TOOLBAR_ITEM_PROJECT);
+    deleteRegisteredProject(HopGui.getInstance().getShell(), projectName);
+  }
+
+  /**
+   * Delete a project registration by name (confirmation + parent-project 
checks). Used by the
+   * toolbar delete action and the projects browser dialog.
+   *
+   * @param parentShell parent for confirmation/error dialogs (must be the 
active modal dialog when
+   *     called from SelectProjectsDialog, otherwise message boxes appear 
behind it)
+   * @param projectName name of the project to remove from hop-config
+   * @return true if the project was removed
+   */
+  public boolean deleteRegisteredProject(Shell parentShell, String 
projectName) {
     if (StringUtils.isEmpty(projectName)) {
-      return;
+      return false;
     }
+    Shell shell = parentShell != null ? parentShell : 
HopGui.getInstance().getShell();
     ProjectsConfig config = ProjectsConfigSingleton.getConfig();
 
     ProjectConfig currentProjectConfig = config.findProjectConfig(projectName);
     if (currentProjectConfig == null) {
-      return;
+      return false;
     }
 
     String projectHome = currentProjectConfig.getProjectHome();
     String configFilename = currentProjectConfig.getConfigFilename();
 
     try {
-
       List<String> refs = ProjectsUtil.getParentProjectReferences(projectName);
 
       if (refs.isEmpty()) {
-        performProjectDeletion(projectName, config, projectHome, 
configFilename);
+        return performProjectDeletion(shell, projectName, config, projectHome, 
configFilename);
       } else {
         String prjReferences = String.join(",", refs);
-        MessageBox box =
-            new MessageBox(HopGui.getInstance().getShell(), SWT.OK | 
SWT.ICON_INFORMATION);
+        MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
         box.setText(
             BaseMessages.getString(
                 PKG, 
"ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Header"));
@@ -1166,21 +1165,33 @@ public class ProjectsGuiPlugin {
                 + BaseMessages.getString(
                     PKG, 
"ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Message2"));
         box.open();
+        return false;
       }
     } catch (Exception e) {
       new ErrorDialog(
-          HopGui.getInstance().getShell(),
+          shell,
           BaseMessages.getString(PKG, 
"ProjectGuiPlugin.DeleteProject.Dialog.Header"),
           BaseMessages.getString(
               PKG, "ProjectGuiPlugin.DeleteProject.Error.Dialog.Message", 
projectName),
           e);
+      return false;
     }
   }
 
-  private void performProjectDeletion(
-      String projectName, ProjectsConfig config, String projectHome, String 
configFilename) {
-    MessageBox box =
-        new MessageBox(HopGui.getInstance().getShell(), SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+  /**
+   * Confirm and remove a project registration from hop-config (does not 
delete files on disk).
+   *
+   * @param parentShell parent for confirmation/error dialogs
+   * @return true if the project was removed
+   */
+  public boolean performProjectDeletion(
+      Shell parentShell,
+      String projectName,
+      ProjectsConfig config,
+      String projectHome,
+      String configFilename) {
+    Shell shell = parentShell != null ? parentShell : 
HopGui.getInstance().getShell();
+    MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
     box.setText(BaseMessages.getString(PKG, 
"ProjectGuiPlugin.DeleteProject.Dialog.Header"));
     box.setMessage(
         BaseMessages.getString(PKG, 
"ProjectGuiPlugin.DeleteProject.Dialog.Message1", projectName)
@@ -1201,15 +1212,17 @@ public class ProjectsGuiPlugin {
         } else {
           updateProjectToolItem(config.getDefaultProject());
         }
+        return true;
       } catch (Exception e) {
         new ErrorDialog(
-            HopGui.getInstance().getShell(),
+            shell,
             BaseMessages.getString(PKG, 
"ProjectGuiPlugin.DeleteProject.Error.Dialog.Header"),
             BaseMessages.getString(
                 PKG, "ProjectGuiPlugin.DeleteProject.Error.Dialog.Message", 
projectName),
             e);
       }
     }
+    return false;
   }
 
   @GuiMenuElement(
@@ -1546,6 +1559,7 @@ public class ProjectsGuiPlugin {
 
     AtomicBoolean includeVariables = new AtomicBoolean(true);
     AtomicBoolean includeMetadata = new AtomicBoolean(true);
+    AtomicBoolean exportAsIs = new AtomicBoolean(false);
     AtomicBoolean cancel = new AtomicBoolean(true);
 
     // After getting the filename we create a tree dialog to select the items 
you wish to include in
@@ -1588,35 +1602,76 @@ public class ProjectsGuiPlugin {
               .bottom(okButton, -2 * PropsUi.getMargin())
               .result());
 
+      Button btnIncludeVariables = new Button(treeShell, SWT.CHECK);
+      PropsUi.setLook(btnIncludeVariables);
+      btnIncludeVariables.setText(
+          BaseMessages.getString(PKG, 
"ProjectGuiPlugin.IncludeVariables.Label"));
+      btnIncludeVariables.setSelection(true);
+      btnIncludeVariables.addListener(
+          SWT.Selection, event -> 
includeVariables.set(btnIncludeVariables.getSelection()));
+      btnIncludeVariables.setLayoutData(
+          new FormDataBuilder().fullWidth().bottom(separator, 
-PropsUi.getMargin()).result());
+
+      // Bottom → top: metadata, as-is, variables, file tree
+      //
       Button btnIncludeMetadata = new Button(treeShell, SWT.CHECK);
       PropsUi.setLook(btnIncludeMetadata);
       btnIncludeMetadata.setText(
           BaseMessages.getString(PKG, 
"ProjectGuiPlugin.IncludeMetadata.Label"));
       btnIncludeMetadata.setSelection(true);
       btnIncludeMetadata.setLayoutData(
-          new FormDataBuilder().fullWidth().bottom(separator, -2 * 
PropsUi.getMargin()).result());
+          new FormDataBuilder()
+              .fullWidth()
+              .bottom(btnIncludeVariables, -2 * PropsUi.getMargin())
+              .result());
       btnIncludeMetadata.addListener(
           SWT.Selection, event -> 
includeMetadata.set(btnIncludeMetadata.getSelection()));
 
-      Button btnIncludeVariables = new Button(treeShell, SWT.CHECK);
-      PropsUi.setLook(btnIncludeVariables);
-      btnIncludeVariables.setText(
-          BaseMessages.getString(PKG, 
"ProjectGuiPlugin.IncludeVariables.Label"));
-      btnIncludeVariables.setSelection(true);
-      btnIncludeVariables.addListener(
-          SWT.Selection, event -> 
includeVariables.set(btnIncludeVariables.getSelection()));
-      btnIncludeVariables.setLayoutData(
+      Button btnExportAsIs = new Button(treeShell, SWT.CHECK);
+      PropsUi.setLook(btnExportAsIs);
+      btnExportAsIs.setText(BaseMessages.getString(PKG, 
"ProjectGuiPlugin.ExportAsIs.Label"));
+      btnExportAsIs.setToolTipText(
+          BaseMessages.getString(PKG, "ProjectGuiPlugin.ExportAsIs.Tooltip"));
+      btnExportAsIs.setSelection(false);
+      btnExportAsIs.setLayoutData(
           new FormDataBuilder()
               .fullWidth()
               .bottom(btnIncludeMetadata, -PropsUi.getMargin())
               .result());
 
+      // Remember selections so we can restore them when "export as-is" is 
turned off.
+      AtomicBoolean savedIncludeVariables = new AtomicBoolean(true);
+      AtomicBoolean savedIncludeMetadata = new AtomicBoolean(true);
+      btnExportAsIs.addListener(
+          SWT.Selection,
+          event -> {
+            boolean asIs = btnExportAsIs.getSelection();
+            exportAsIs.set(asIs);
+            if (asIs) {
+              savedIncludeVariables.set(btnIncludeVariables.getSelection());
+              savedIncludeMetadata.set(btnIncludeMetadata.getSelection());
+              btnIncludeVariables.setSelection(false);
+              btnIncludeMetadata.setSelection(false);
+              includeVariables.set(false);
+              includeMetadata.set(false);
+              btnIncludeVariables.setEnabled(false);
+              btnIncludeMetadata.setEnabled(false);
+            } else {
+              btnIncludeVariables.setEnabled(true);
+              btnIncludeMetadata.setEnabled(true);
+              btnIncludeVariables.setSelection(savedIncludeVariables.get());
+              btnIncludeMetadata.setSelection(savedIncludeMetadata.get());
+              includeVariables.set(savedIncludeVariables.get());
+              includeMetadata.set(savedIncludeMetadata.get());
+            }
+          });
+
       tree = new FileTree(treeShell, HopVfs.getFileObject(projectHome), 
projectName);
       tree.setLayoutData(
           new FormDataBuilder()
               .top()
               .fullWidth()
-              .bottom(btnIncludeVariables, -PropsUi.getMargin())
+              .bottom(btnExportAsIs, -PropsUi.getMargin())
               .result());
 
       BaseTransformDialog.setSize(treeShell);
@@ -1640,47 +1695,19 @@ public class ProjectsGuiPlugin {
             try {
               monitor.setTaskName(
                   BaseMessages.getString(PKG, 
"ProjectGuiPlugin.ZipDirectory.Taskname.Text"));
-              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();
+              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);
-              String projectHomeFolder =
-                  
HopVfs.getFileObject(projectHome).getParent().getName().getURI();
+              // 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()) {
@@ -1690,30 +1717,71 @@ public class ProjectsGuiPlugin {
                     continue;
                   }
                   monitor.subTask(fileObject.getName().getURI());
-                  zipFile(fileObject, fileObject.getName().getURI(), zos, 
projectHomeFolder);
+                  zipFile(fileObject, fileObject.getName().getURI(), zos, 
stripBase);
                 }
               }
 
-              // 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,
-                  projectHomeFolder);
-
-              // Includes variables
-              if (includeVariables.get()) {
-                zipString(
-                    variablesJson, "variables.json", zos, 
projectDirectory.getName().getBaseName());
-              }
+              // 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",
+                      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();
@@ -1746,6 +1814,36 @@ public class ProjectsGuiPlugin {
     }
   }
 
+  /**
+   * Build a zip entry path by stripping a base URI/path prefix from an 
absolute file URI.
+   *
+   * @param absoluteUri absolute file URI or path
+   * @param stripBase URI/path prefix to remove (project home or its parent)
+   * @return relative zip entry path without a leading slash
+   */
+  static String relativeZipPath(String absoluteUri, String stripBase) {
+    if (absoluteUri == null) {
+      return "";
+    }
+    String path = absoluteUri;
+    if (StringUtils.isNotEmpty(stripBase)) {
+      String base = stripBase;
+      // Normalize trailing slashes so file://…/project and file://…/project/ 
both strip cleanly
+      while (base.endsWith("/")) {
+        base = base.substring(0, base.length() - 1);
+      }
+      if (path.startsWith(base)) {
+        path = path.substring(base.length());
+      } else {
+        path = path.replace(stripBase, "");
+      }
+    }
+    while (path.startsWith("/")) {
+      path = path.substring(1);
+    }
+    return path;
+  }
+
   /**
    * Add file Object to zip file
    *
@@ -1766,10 +1864,7 @@ public class ProjectsGuiPlugin {
     }
 
     // Build relative filename
-    filename = filename.replace(projectHomeParent, "");
-    if (filename.startsWith("/")) {
-      filename = filename.substring(1);
-    }
+    filename = relativeZipPath(filename, projectHomeParent);
 
     zipOutputStream.putNextEntry(new ZipEntry(filename));
     if (fileToZip.isFolder()) {
@@ -1777,9 +1872,10 @@ public class ProjectsGuiPlugin {
       for (FileObject childFile : fileToZip.getChildren()) {
         zipFile(
             childFile,
+            // Pass already-relative path so nested calls do not re-strip 
incorrectly
             filename + "/" + childFile.getName().getBaseName(),
             zipOutputStream,
-            projectHomeParent);
+            "");
       }
     } else {
       InputStream fis = HopVfs.getInputStream(fileToZip);
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
new file mode 100644
index 0000000000..bb7062ea41
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
@@ -0,0 +1,394 @@
+/*
+ * 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.projects.gui;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.history.AuditEvent;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.util.ProjectsUtil;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Dialog listing all registered projects with filter, metadata columns, and 
the ability to edit a
+ * registration or open the selected project.
+ */
+public class SelectProjectsDialog extends Dialog {
+  private static final Class<?> PKG = SelectProjectsDialog.class;
+
+  /** TableView column index for project name (0 is the row-number column). */
+  private static final int COL_NAME = 1;
+
+  private final PropsUi props;
+  private final IVariables variables;
+
+  private Shell shell;
+  private Text wFilter;
+  private TableView wTable;
+  private Button wEdit;
+  private Button wDelete;
+  private Button wOk;
+
+  private String selectedProjectName;
+
+  /**
+   * Cached last-used timestamps from AuditManager for the lifetime of this 
dialog. Loaded once so
+   * filter typing does not re-query audit storage on every keystroke.
+   */
+  private Map<String, String> lastUsedDisplayByProject;
+
+  public SelectProjectsDialog(Shell parent, IVariables variables) {
+    super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
+    this.variables = variables;
+    this.props = PropsUi.getInstance();
+  }
+
+  /**
+   * Open the dialog.
+   *
+   * @return selected project name to open, or {@code null} if cancelled
+   */
+  public String open() {
+    Shell parent = getParent();
+    shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | 
SWT.RESIZE);
+    shell.setText(BaseMessages.getString(PKG, 
"SelectProjectsDialog.Shell.Name"));
+    shell.setImage(
+        GuiResource.getInstance()
+            .getImage(
+                "project.svg",
+                PKG.getClassLoader(),
+                ConstUi.SMALL_ICON_SIZE,
+                ConstUi.SMALL_ICON_SIZE));
+    PropsUi.setLook(shell);
+
+    int margin = PropsUi.getMargin();
+
+    FormLayout formLayout = new FormLayout();
+    formLayout.marginWidth = PropsUi.getFormMargin();
+    formLayout.marginHeight = PropsUi.getFormMargin();
+    shell.setLayout(formLayout);
+
+    shell.setMinimumSize(700, 450);
+
+    wOk = new Button(shell, SWT.PUSH);
+    wOk.setText(BaseMessages.getString("System.Button.OK"));
+    wOk.addListener(SWT.Selection, e -> ok());
+    wOk.setEnabled(false);
+
+    wEdit = new Button(shell, SWT.PUSH);
+    wEdit.setText(BaseMessages.getString(PKG, 
"SelectProjectsDialog.Button.Edit"));
+    wEdit.setEnabled(false);
+    wEdit.addListener(SWT.Selection, e -> editSelected());
+
+    wDelete = new Button(shell, SWT.PUSH);
+    wDelete.setText(BaseMessages.getString(PKG, 
"SelectProjectsDialog.Button.Delete"));
+    wDelete.setEnabled(false);
+    wDelete.addListener(SWT.Selection, e -> deleteSelected());
+
+    Button wCancel = new Button(shell, SWT.PUSH);
+    wCancel.setText(BaseMessages.getString("System.Button.Cancel"));
+    wCancel.addListener(SWT.Selection, e -> cancel());
+
+    BaseTransformDialog.positionBottomButtons(
+        shell, new Button[] {wOk, wEdit, wDelete, wCancel}, margin * 3, null);
+
+    Label wlFilter = new Label(shell, SWT.LEFT);
+    PropsUi.setLook(wlFilter);
+    wlFilter.setText(BaseMessages.getString(PKG, 
"SelectProjectsDialog.Label.Filter"));
+    FormData fdlFilter = new FormData();
+    fdlFilter.left = new FormAttachment(0, 0);
+    fdlFilter.top = new FormAttachment(0, margin);
+    wlFilter.setLayoutData(fdlFilter);
+
+    wFilter = new Text(shell, SWT.SINGLE | SWT.BORDER | SWT.SEARCH | 
SWT.ICON_CANCEL);
+    PropsUi.setLook(wFilter);
+    wFilter.setToolTipText(
+        BaseMessages.getString(PKG, 
"SelectProjectsDialog.Label.Filter.Tooltip"));
+    FormData fdFilter = new FormData();
+    fdFilter.left = new FormAttachment(wlFilter, margin);
+    fdFilter.right = new FormAttachment(100, 0);
+    fdFilter.top = new FormAttachment(wlFilter, 0, SWT.CENTER);
+    wFilter.setLayoutData(fdFilter);
+    wFilter.addModifyListener(e -> refreshTable());
+
+    ColumnInfo[] columns =
+        new ColumnInfo[] {
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"SelectProjectsDialog.Table.Col.Name"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              true),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"SelectProjectsDialog.Table.Col.Group"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              true),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"SelectProjectsDialog.Table.Col.Folder"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              true),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"SelectProjectsDialog.Table.Col.Tags"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              true),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"SelectProjectsDialog.Table.Col.LastUsed"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              true),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"SelectProjectsDialog.Table.Col.ReadOnly"),
+              ColumnInfo.COLUMN_TYPE_TEXT,
+              false,
+              true),
+        };
+
+    wTable =
+        new TableView(
+            variables,
+            shell,
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.H_SCROLL | 
SWT.V_SCROLL,
+            columns,
+            0,
+            null,
+            props);
+    wTable.setReadonly(true);
+    PropsUi.setLook(wTable);
+    FormData fdTable = new FormData();
+    fdTable.left = new FormAttachment(0, 0);
+    fdTable.right = new FormAttachment(100, 0);
+    fdTable.top = new FormAttachment(wFilter, margin);
+    fdTable.bottom = new FormAttachment(wOk, -margin * 2);
+    wTable.setLayoutData(fdTable);
+
+    wTable.table.addListener(SWT.Selection, e -> updateButtons());
+    wTable.table.addListener(SWT.DefaultSelection, e -> ok());
+
+    // Load audit last-used once for this dialog instance (filter uses the 
cache).
+    loadLastUsedCache();
+    refreshTable();
+    selectCurrentProject();
+
+    shell.setDefaultButton(wOk);
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+    return selectedProjectName;
+  }
+
+  /**
+   * Load project "open" events from AuditManager into {@link 
#lastUsedDisplayByProject}. Called
+   * once per dialog open so filter keystrokes stay cheap.
+   */
+  private void loadLastUsedCache() {
+    lastUsedDisplayByProject = new HashMap<>();
+    try {
+      List<AuditEvent> projectOpenEvents =
+          AuditManager.findEvents(
+              ProjectsUtil.STRING_PROJECTS_AUDIT_GROUP,
+              ProjectsUtil.STRING_PROJECT_AUDIT_TYPE,
+              "open",
+              100,
+              true);
+      DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+      for (AuditEvent event : projectOpenEvents) {
+        if (StringUtils.isEmpty(event.getName()) || event.getDate() == null) {
+          continue;
+        }
+        lastUsedDisplayByProject.put(event.getName(), 
dateFormat.format(event.getDate()));
+      }
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error reading last used project dates from the 
audit logs", e);
+    }
+  }
+
+  private String formatLastUsed(String projectName) {
+    if (lastUsedDisplayByProject == null || projectName == null) {
+      return "";
+    }
+    return Const.NVL(lastUsedDisplayByProject.get(projectName), "");
+  }
+
+  private void refreshTable() {
+    String previouslySelected = getSelectedProjectName();
+    String filter = wFilter.getText();
+
+    List<ProjectConfig> projects =
+        new 
ArrayList<>(ProjectsConfigSingleton.getConfig().getProjectConfigurations());
+    projects.sort(
+        Comparator.comparing(
+                (ProjectConfig p) -> Const.NVL(p.getGroup(), ""), 
String.CASE_INSENSITIVE_ORDER)
+            .thenComparing(p -> Const.NVL(p.getProjectName(), ""), 
String.CASE_INSENSITIVE_ORDER));
+
+    wTable.table.removeAll();
+    for (ProjectConfig projectConfig : projects) {
+      if (!projectConfig.matchesFilter(filter)) {
+        continue;
+      }
+      TableItem item = new TableItem(wTable.table, SWT.NONE);
+      // Column 0 is the TableView row-number column; name is always column 1
+      item.setText(COL_NAME, Const.NVL(projectConfig.getProjectName(), ""));
+      item.setText(2, Const.NVL(projectConfig.getGroup(), ""));
+      item.setText(3, Const.NVL(projectConfig.getProjectHome(), ""));
+      item.setText(4, projectConfig.getTagsAsDisplayString());
+      item.setText(5, formatLastUsed(projectConfig.getProjectName()));
+      item.setText(
+          6,
+          projectConfig.isReadOnly()
+              ? BaseMessages.getString(PKG, "SelectProjectsDialog.Table.Yes")
+              : BaseMessages.getString(PKG, "SelectProjectsDialog.Table.No"));
+    }
+    wTable.optimizeTableView();
+
+    if (StringUtils.isNotEmpty(previouslySelected)) {
+      selectProjectByName(previouslySelected);
+    }
+    updateButtons();
+  }
+
+  private void selectCurrentProject() {
+    String current = HopNamespace.getNamespace();
+    if (StringUtils.isNotEmpty(current)) {
+      selectProjectByName(current);
+    }
+    updateButtons();
+  }
+
+  private void selectProjectByName(String name) {
+    for (TableItem item : wTable.table.getItems()) {
+      if (name.equals(item.getText(COL_NAME))) {
+        wTable.table.setSelection(item);
+        wTable.table.showSelection();
+        return;
+      }
+    }
+  }
+
+  /**
+   * Project name from the selected row. Always use column 1 (Name) — 
TableView sort can recreate
+   * items so item data is not reliable after header clicks.
+   */
+  private String getSelectedProjectName() {
+    int index = wTable.getSelectionIndex();
+    if (index < 0 || index >= wTable.table.getItemCount()) {
+      return null;
+    }
+    String name = wTable.table.getItem(index).getText(COL_NAME);
+    return StringUtils.isEmpty(name) ? null : name;
+  }
+
+  private void updateButtons() {
+    boolean hasSelection = StringUtils.isNotEmpty(getSelectedProjectName());
+    wOk.setEnabled(hasSelection);
+    wEdit.setEnabled(hasSelection);
+    wDelete.setEnabled(hasSelection);
+  }
+
+  private void editSelected() {
+    String name = getSelectedProjectName();
+    if (name == null) {
+      return;
+    }
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    ProjectConfig projectConfig = config.findProjectConfig(name);
+    if (projectConfig == null) {
+      return;
+    }
+
+    ProjectConfigDialog editDialog = new ProjectConfigDialog(shell, 
projectConfig, variables);
+    if (editDialog.open()) {
+      try {
+        config.updateProjectConfig(editDialog.getOriginalName(), 
projectConfig);
+        ProjectsConfigSingleton.saveConfig();
+        refreshTable();
+        selectProjectByName(projectConfig.getProjectName());
+        updateButtons();
+      } catch (Exception e) {
+        new ErrorDialog(
+            shell,
+            BaseMessages.getString(PKG, 
"SelectProjectsDialog.Error.Save.Header"),
+            BaseMessages.getString(PKG, 
"SelectProjectsDialog.Error.Save.Message"),
+            e);
+      }
+    }
+  }
+
+  private void deleteSelected() {
+    String name = getSelectedProjectName();
+    if (name == null) {
+      return;
+    }
+    // Re-use the same confirmation / parent-reference / hop-config removal as 
the toolbar action.
+    // Pass this dialog shell so MessageBox/ErrorDialog are modal children 
(not the main Hop shell).
+    boolean deleted = new ProjectsGuiPlugin().deleteRegisteredProject(shell, 
name);
+    if (deleted) {
+      refreshTable();
+      updateButtons();
+    }
+  }
+
+  private void ok() {
+    selectedProjectName = getSelectedProjectName();
+    if (selectedProjectName == null) {
+      return;
+    }
+    dispose();
+  }
+
+  private void cancel() {
+    selectedProjectName = null;
+    dispose();
+  }
+
+  private void dispose() {
+    props.setScreen(new WindowProperty(shell));
+    shell.dispose();
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
index 97aa3ea80c..15bd6104ae 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
@@ -17,8 +17,14 @@
 
 package org.apache.hop.projects.project;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
 import java.util.Objects;
+import java.util.stream.Collectors;
 import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.variables.IVariables;
@@ -26,10 +32,36 @@ import org.apache.hop.core.vfs.HopVfs;
 
 public class ProjectConfig {
 
+  /**
+   * VFS schemes that provide read-only access to archive contents (Zip, Jar 
and Tar family). See
+   * 
https://commons.apache.org/proper/commons-vfs/filesystems.html#Zip.2C_Jar_and_Tar
+   */
+  private static final String[] ARCHIVE_URI_SCHEMES = {
+    "zip:", "jar:", "tar:", "tgz:", "tbz2:",
+  };
+
   protected String projectName;
   protected String projectHome;
   protected String configFilename;
 
+  /**
+   * When true, Hop will not write to the project's configuration file 
(project-config.json). Useful
+   * for projects opened from archives (zip/jar/tar), HTTP locations, or other 
read-only folders.
+   */
+  protected boolean readOnly;
+
+  /**
+   * Optional organization group for this project registration (stored in 
hop-config.json), e.g.
+   * "Clients" or "Legacy". Empty means ungrouped.
+   */
+  protected String group;
+
+  /**
+   * Optional tags for filtering and organization (stored in hop-config.json). 
A project may have
+   * multiple tags.
+   */
+  protected List<String> tags = new ArrayList<>();
+
   public ProjectConfig() {
     super();
   }
@@ -41,6 +73,26 @@ public class ProjectConfig {
     this.configFilename = configFilename;
   }
 
+  /**
+   * Returns true when the given path is a Commons VFS archive URI (zip, jar, 
tar, tgz, tbz2),
+   * including nested forms such as {@code jar:zip:outer.zip!/nested.jar!/dir}.
+   *
+   * @param path project home path or URI (may be null/empty)
+   * @return true if the path uses an archive scheme
+   */
+  public static boolean isArchiveUri(String path) {
+    if (StringUtils.isEmpty(path)) {
+      return false;
+    }
+    String lower = path.trim().toLowerCase(Locale.ROOT);
+    for (String scheme : ARCHIVE_URI_SCHEMES) {
+      if (lower.startsWith(scheme)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) {
@@ -79,6 +131,14 @@ public class ProjectConfig {
                 + "' does not exist");
       }
       String actualConfigFilename = variables.resolve(getConfigFilename());
+      // Use VFS resolve so archive/HTTP URIs work (FilenameUtils.concat 
mangles schemes).
+      // For plain local paths keep the previous FilenameUtils behaviour for 
compatibility.
+      //
+      String scheme = actualHome.getName().getScheme();
+      if (scheme != null && !"file".equalsIgnoreCase(scheme)) {
+        FileObject configFile = actualHome.resolveFile(actualConfigFilename);
+        return configFile.getName().getURI();
+      }
       String fullFilename = FilenameUtils.concat(actualHome.toString(), 
actualConfigFilename);
       if (fullFilename == null) {
         throw new HopException(
@@ -165,4 +225,115 @@ public class ProjectConfig {
   public void setConfigFilename(String configFilename) {
     this.configFilename = configFilename;
   }
+
+  /**
+   * Gets readOnly
+   *
+   * @return value of readOnly
+   */
+  public boolean isReadOnly() {
+    return readOnly;
+  }
+
+  /**
+   * @param readOnly The readOnly flag to set
+   */
+  public void setReadOnly(boolean readOnly) {
+    this.readOnly = readOnly;
+  }
+
+  /**
+   * Gets group
+   *
+   * @return value of group
+   */
+  public String getGroup() {
+    return group;
+  }
+
+  /**
+   * @param group The group to set
+   */
+  public void setGroup(String group) {
+    this.group = group;
+  }
+
+  /**
+   * Gets tags
+   *
+   * @return value of tags (never null)
+   */
+  public List<String> getTags() {
+    if (tags == null) {
+      tags = new ArrayList<>();
+    }
+    return tags;
+  }
+
+  /**
+   * @param tags The tags to set
+   */
+  public void setTags(List<String> tags) {
+    this.tags = tags != null ? new ArrayList<>(tags) : new ArrayList<>();
+  }
+
+  /**
+   * Comma-separated display form of tags (for tables and text fields).
+   *
+   * @return joined tags, or empty string
+   */
+  public String getTagsAsDisplayString() {
+    if (tags == null || tags.isEmpty()) {
+      return "";
+    }
+    return String.join(", ", tags);
+  }
+
+  /**
+   * Parse a comma/semicolon-separated tag string into a list of trimmed 
non-empty tags.
+   *
+   * @param text free-form tags text
+   * @return list of tags (never null)
+   */
+  public static List<String> parseTags(String text) {
+    if (StringUtils.isEmpty(text)) {
+      return new ArrayList<>();
+    }
+    return Arrays.stream(text.split("[,;]"))
+        .map(String::trim)
+        .filter(StringUtils::isNotEmpty)
+        .collect(Collectors.toCollection(ArrayList::new));
+  }
+
+  /**
+   * Whether this project registration matches a free-text filter (name, 
group, home, tags).
+   *
+   * @param filter filter text (null/empty matches all)
+   * @return true if the project should be shown
+   */
+  public boolean matchesFilter(String filter) {
+    if (StringUtils.isEmpty(filter)) {
+      return true;
+    }
+    String needle = filter.trim().toLowerCase(Locale.ROOT);
+    if (containsIgnoreCase(projectName, needle)
+        || containsIgnoreCase(group, needle)
+        || containsIgnoreCase(projectHome, needle)
+        || containsIgnoreCase(configFilename, needle)
+        || containsIgnoreCase(getTagsAsDisplayString(), needle)) {
+      return true;
+    }
+    if (tags != null) {
+      for (String tag : tags) {
+        if (containsIgnoreCase(tag, needle)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private static boolean containsIgnoreCase(String value, String needleLower) {
+    return value != null && 
value.toLowerCase(Locale.ROOT).contains(needleLower);
+  }
 }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
index d31d7bd051..188acfd1ff 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
@@ -80,8 +80,10 @@ public class ProjectDialog extends Dialog {
 
   private Text wName;
   private TextVar wHome;
+  private Button wReadOnly;
   private ComboVar wParentProject;
   private TextVar wConfigFile;
+  private Button wbConfigFile;
   private Text wDescription;
   private Text wCompany;
   private Text wDepartment;
@@ -226,6 +228,19 @@ public class ProjectDialog extends Dialog {
     wHome.setLayoutData(fdHome);
     lastControl = wHome;
 
+    // Read-only option below the home folder path (auto-enabled for archive 
URIs)
+    //
+    wReadOnly = new Button(comp, SWT.CHECK | SWT.LEFT);
+    PropsUi.setLook(wReadOnly);
+    wReadOnly.setText(BaseMessages.getString(PKG, 
"ProjectDialog.Label.ReadOnly"));
+    FormData fdReadOnly = new FormData();
+    fdReadOnly.left = new FormAttachment(middle, margin);
+    fdReadOnly.right = new FormAttachment(99, 0);
+    fdReadOnly.top = new FormAttachment(lastControl, margin);
+    wReadOnly.setLayoutData(fdReadOnly);
+    wReadOnly.addListener(SWT.Selection, e -> updateReadOnlyWidgets());
+    lastControl = wReadOnly;
+
     Label wlConfigFile = new Label(comp, SWT.RIGHT);
     PropsUi.setLook(wlConfigFile);
     wlConfigFile.setText(BaseMessages.getString(PKG, 
"ProjectDialog.Label.ConfigurationFile"));
@@ -234,7 +249,7 @@ public class ProjectDialog extends Dialog {
     fdlConfigFile.right = new FormAttachment(middle, 0);
     fdlConfigFile.top = new FormAttachment(lastControl, margin);
     wlConfigFile.setLayoutData(fdlConfigFile);
-    Button wbConfigFile = new Button(comp, SWT.PUSH);
+    wbConfigFile = new Button(comp, SWT.PUSH);
     PropsUi.setLook(wbConfigFile);
     wbConfigFile.setText(BaseMessages.getString(PKG, 
"ProjectDialog.Button.Browse"));
     FormData fdbConfigFile = new FormData();
@@ -463,9 +478,14 @@ public class ProjectDialog extends Dialog {
     // See if we need a project refresh/reload
     //
     wParentProject.addModifyListener(e -> needingProjectRefresh = true);
-    wHome.addModifyListener(e -> needingProjectRefresh = true);
+    wHome.addModifyListener(
+        e -> {
+          needingProjectRefresh = true;
+          autoSetReadOnlyFromHome();
+        });
 
     getData();
+    updateReadOnlyWidgets();
 
     comp.pack();
     scroll.setContent(comp);
@@ -478,6 +498,43 @@ public class ProjectDialog extends Dialog {
     return returnValue;
   }
 
+  /**
+   * Automatically select read-only when the home folder is a VFS archive URI 
(zip/jar/tar/...). The
+   * user can still uncheck the option, or check it manually for other cases 
(http://, read-only
+   * folders, ...).
+   */
+  private void autoSetReadOnlyFromHome() {
+    if (ProjectConfig.isArchiveUri(variables.resolve(wHome.getText()))) {
+      if (!wReadOnly.getSelection()) {
+        wReadOnly.setSelection(true);
+        updateReadOnlyWidgets();
+      }
+    }
+  }
+
+  /**
+   * Enable or disable project settings that are stored in 
project-config.json. When the project is
+   * read-only those cannot be persisted, so the widgets are disabled. Name, 
home folder, config
+   * path and the read-only flag remain editable (they live in 
hop-config.json).
+   */
+  private void updateReadOnlyWidgets() {
+    boolean editable = !wReadOnly.getSelection();
+
+    // Config file browse is of limited use for archives; keep the relative 
path editable.
+    wbConfigFile.setEnabled(editable);
+    wParentProject.setEnabled(editable);
+    wDescription.setEnabled(editable);
+    wCompany.setEnabled(editable);
+    wDepartment.setEnabled(editable);
+    wVersion.setEnabled(editable);
+    wMetadataBaseFolder.setEnabled(editable);
+    wUnitTestsBasePath.setEnabled(editable);
+    wDataSetCsvFolder.setEnabled(editable);
+    wEnforceHomeExecution.setEnabled(editable);
+    wVariables.setEnabled(editable);
+    wVariables.setReadonly(!editable);
+  }
+
   private void browseHomeFolder(Event event) {
     String homeFolder = BaseDialog.presentDirectoryDialog(shell, wHome, 
variables);
 
@@ -574,6 +631,7 @@ public class ProjectDialog extends Dialog {
 
       String homeFolder = wHome.getText();
       boolean projectHomeFolderChanged = this.editMode && 
!oriProjectHome.equals(homeFolder);
+      boolean readOnly = wReadOnly.getSelection();
 
       if (StringUtils.isEmpty(variables.resolve(homeFolder))) {
         throw new HopException("Please specify a home folder for your 
project");
@@ -593,16 +651,24 @@ public class ProjectDialog extends Dialog {
         }
       }
 
-      // If the home folder doesn't exist and project is new aks if want it 
created
-      if (!HopVfs.getFileObject(variables.resolve(homeFolder)).exists()
-          && (!this.editMode || projectHomeFolderChanged)) {
-        MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
-        box.setText(BaseMessages.getString(PKG, 
"ProjectDialog.CreateHome.Dialog.Header"));
-        box.setMessage(
-            BaseMessages.getString(PKG, 
"ProjectDialog.CreateHome.Dialog.Message", homeFolder));
-        int anwser = box.open();
-        if ((anwser & SWT.YES) != 0) {
-          HopVfs.getFileObject(homeFolder).createFolder();
+      // If the home folder doesn't exist and project is new ask if it should 
be created.
+      // Never create folders for a read-only project (archives, HTTP, etc.).
+      //
+      FileObject homeFolderObject = 
HopVfs.getFileObject(variables.resolve(homeFolder));
+      if (!homeFolderObject.exists()) {
+        if (readOnly) {
+          throw new HopException(
+              BaseMessages.getString(PKG, 
"ProjectDialog.ReadOnly.HomeMissing.Error", homeFolder));
+        }
+        if (!this.editMode || projectHomeFolderChanged) {
+          MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+          box.setText(BaseMessages.getString(PKG, 
"ProjectDialog.CreateHome.Dialog.Header"));
+          box.setMessage(
+              BaseMessages.getString(PKG, 
"ProjectDialog.CreateHome.Dialog.Message", homeFolder));
+          int anwser = box.open();
+          if ((anwser & SWT.YES) != 0) {
+            HopVfs.getFileObject(homeFolder).createFolder();
+          }
         }
       }
 
@@ -620,6 +686,20 @@ public class ProjectDialog extends Dialog {
         throw new HopException("Please specify project's configuration file 
relative path!");
       }
 
+      // Read-only projects cannot create/write project-config.json: it must 
already exist.
+      //
+      if (readOnly) {
+        ProjectConfig verifyConfig =
+            new ProjectConfig(projectName, homeFolder, wConfigFile.getText());
+        String configPath = 
verifyConfig.getActualProjectConfigFilename(variables);
+        FileObject configFile = HopVfs.getFileObject(configPath);
+        if (!configFile.exists()) {
+          throw new HopException(
+              BaseMessages.getString(
+                  PKG, "ProjectDialog.ReadOnly.ConfigMissing.Error", 
configPath));
+        }
+      }
+
       if (wParentProject.getText() != null
           && !wParentProject.getText().isEmpty()
           && projectName.equals(wParentProject.getText())) {
@@ -716,6 +796,9 @@ public class ProjectDialog extends Dialog {
     wName.setText(Const.NVL(projectConfig.getProjectName(), ""));
     wHome.setText(Const.NVL(projectConfig.getProjectHome(), ""));
     wConfigFile.setText(Const.NVL(projectConfig.getConfigFilename(), ""));
+    wReadOnly.setSelection(
+        projectConfig.isReadOnly()
+            || 
ProjectConfig.isArchiveUri(variables.resolve(projectConfig.getProjectHome())));
 
     wDescription.setText(Const.NVL(project.getDescription(), ""));
     wCompany.setText(Const.NVL(project.getCompany(), ""));
@@ -759,6 +842,7 @@ public class ProjectDialog extends Dialog {
     projectConfig.setProjectName(wName.getText());
     projectConfig.setProjectHome(wHome.getText());
     projectConfig.setConfigFilename(wConfigFile.getText());
+    projectConfig.setReadOnly(wReadOnly.getSelection());
 
     project.setParentProjectName(wParentProject.getText());
     project.setDescription(wDescription.getText());
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
index 9205198991..e829e8e681 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
@@ -66,6 +66,36 @@ 
ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Message1=Current projec
 ProjectGuiPlugin.DeleteProject.ProjectReferencedAsParent.Message2=To perform 
the deletion, you MUST firstly remove the parent project reference in the 
listed projects and then retry.
 ProjectGuiPlugin.Dialog.AvailableProjects.Title=Available projects
 ProjectGuiPlugin.Dialog.AvailableProjects.Message=Select a project to activate
+SelectProjectsDialog.Shell.Name=Projects
+SelectProjectsDialog.Label.Filter=Filter:
+SelectProjectsDialog.Label.Filter.Tooltip=Filter by name, group, folder or tags
+SelectProjectsDialog.Button.Edit=Edit...
+SelectProjectsDialog.Button.Delete=Delete...
+SelectProjectsDialog.Table.Col.Name=Name
+SelectProjectsDialog.Table.Col.Group=Group
+SelectProjectsDialog.Table.Col.Folder=Folder
+SelectProjectsDialog.Table.Col.Tags=Tags
+SelectProjectsDialog.Table.Col.LastUsed=Last used
+SelectProjectsDialog.Table.Col.ReadOnly=Read only
+SelectProjectsDialog.Table.Yes=Yes
+SelectProjectsDialog.Table.No=No
+SelectProjectsDialog.Error.Save.Header=Error
+SelectProjectsDialog.Error.Save.Message=Error saving project registration
+ProjectConfigDialog.Shell.Name=Project registration
+ProjectConfigDialog.Label.Name=Name
+ProjectConfigDialog.Label.Home=Home folder
+ProjectConfigDialog.Label.ConfigFile=Configuration file (relative path)
+ProjectConfigDialog.Label.Group=Group
+ProjectConfigDialog.Label.Tags=Tags
+ProjectConfigDialog.Label.Tags.Tooltip=Comma-separated tags (e.g. etl, 
customer-a, legacy)
+ProjectConfigDialog.Label.ReadOnly=This project is read only
+ProjectConfigDialog.Button.Browse=Browse...
+ProjectConfigDialog.Error.Header=Error
+ProjectConfigDialog.Error.Message=Error updating project registration
+ProjectConfigDialog.Error.NameRequired=Please give the project a name
+ProjectConfigDialog.Error.HomeRequired=Please specify the project home folder
+ProjectConfigDialog.Error.ConfigRequired=Please specify the project 
configuration file path
+ProjectConfigDialog.Error.NameExists=Project ''{0}'' already exists. Project 
name must be unique.
 ProjectGuiPlugin.EditEnvironment.Error.Dialog.Header=Error
 ProjectGuiPlugin.EditEnvironment.Error.Dialog.Message=Error editing 
environment ''{0}''
 ProjectGuiPlugin.EditProject.Error.Dialog.Header=Error
@@ -90,6 +120,8 @@ ProjectGuiPlugin.ZipDirectory.Error.Dialog.Header=Error
 ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message=Error zipping project
 ProjectGuiPlugin.ZipDirectory.Taskname.Text=Zipping project directory...
 ProjectGuiPlugin.IncludeVariables.Label=Include variables in export
+ProjectGuiPlugin.ExportAsIs.Label=Export project as-is
+ProjectGuiPlugin.ExportAsIs.Tooltip=Copy selected files into the zip root 
without a project sub-folder, and without exporting variables or metadata. Use 
this to open the zip as a read-only project.
 ProjectGuiPlugin.IncludeMetadata.Label=Include metadata in export
 EmptyProjectDialog.Shell.Name=Create empty project
 EmptyProjectDialog.Label.ProjectName=Project name
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
index c0265b9497..79b0fae76d 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
@@ -42,6 +42,7 @@ ProjectDialog.Label.HomeFolder=Home folder
 ProjectDialog.Label.MetadataBaseFolder=Metadata base folder 
(HOP_METADATA_FOLDER)
 ProjectDialog.Label.ParentProject=Parent project to inherit from
 ProjectDialog.Label.ProjectName=Name
+ProjectDialog.Label.ReadOnly=This project is read only
 ProjectDialog.Label.UnitTestBaseFolder=Unit tests base path 
(HOP_UNIT_TESTS_FOLDER)
 ProjectDialog.Label.Version=Version
 ProjectDialog.ProjectConfigError.Error.Dialog.Header=Error
@@ -50,6 +51,8 @@ ProjectDialog.ProjectDefinitionError.Error.Dialog.Header=Error
 ProjectDialog.ProjectDefinitionError.Error.Dialog.Message=There is an error in 
the project definition, variables couldn''t be set
 ProjectDialog.ProjectList.Error.Dialog.Header=Error
 ProjectDialog.ProjectList.Error.Dialog.Message=Error getting list of project 
names
+ProjectDialog.ReadOnly.ConfigMissing.Error=This project is read only, so the 
configuration file must already exist.\nFile not found: {0}
+ProjectDialog.ReadOnly.HomeMissing.Error=This project is read only, so the 
home folder must already exist.\nFolder not found: {0}
 ProjectDialog.Shell.Name=Project Properties
 ProjectGuiPlugin.WrongConfigPath.Dialog.Header=Wrong project''s root
 ProjectGuiPlugin.WrongConfigPath.Dialog.Message=The path to selected 
project-config.json is outside the boundaries of this project and that is not 
permitted!
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectsGuiPluginZipTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectsGuiPluginZipTest.java
new file mode 100644
index 0000000000..f47ae6ceb4
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/gui/ProjectsGuiPluginZipTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.projects.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class ProjectsGuiPluginZipTest {
+
+  @Test
+  public void testRelativeZipPathAsIsUsesProjectHomeAsRoot() {
+    String projectHome = "file:///home/user/my-project";
+    String file = "file:///home/user/my-project/pipelines/demo.hpl";
+    assertEquals("pipelines/demo.hpl", ProjectsGuiPlugin.relativeZipPath(file, 
projectHome));
+  }
+
+  @Test
+  public void testRelativeZipPathAsIsWithTrailingSlashOnHome() {
+    String projectHome = "file:///home/user/my-project/";
+    String file = "file:///home/user/my-project/project-config.json";
+    assertEquals("project-config.json", 
ProjectsGuiPlugin.relativeZipPath(file, projectHome));
+  }
+
+  @Test
+  public void testRelativeZipPathNormalNestsUnderProjectName() {
+    String parent = "file:///home/user";
+    String file = "file:///home/user/my-project/pipelines/demo.hpl";
+    assertEquals("my-project/pipelines/demo.hpl", 
ProjectsGuiPlugin.relativeZipPath(file, parent));
+  }
+
+  @Test
+  public void testRelativeZipPathAlreadyRelativeUnchanged() {
+    assertEquals(
+        "my-project/hop-config.json",
+        ProjectsGuiPlugin.relativeZipPath("my-project/hop-config.json", 
"file:///home/user"));
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
index a9e67cc5cb..976605048e 100644
--- 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java
@@ -28,6 +28,8 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.projects.config.ProjectsConfig;
@@ -174,6 +176,139 @@ public class ProjectTest {
     assertEquals(childHome.toString(), 
variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME));
   }
 
+  @Test
+  public void testIsArchiveUri() {
+    
assertTrue(ProjectConfig.isArchiveUri("zip:file:///tmp/project.zip!/my-project"));
+    
assertTrue(ProjectConfig.isArchiveUri("zip:http://example.com/p.zip!/folder";));
+    
assertTrue(ProjectConfig.isArchiveUri("jar:file:///tmp/lib.jar!/META-INF"));
+    assertTrue(ProjectConfig.isArchiveUri("tar:file:///tmp/a.tar!/proj"));
+    assertTrue(ProjectConfig.isArchiveUri("tgz:file:///tmp/a.tgz!/proj"));
+    assertTrue(ProjectConfig.isArchiveUri("tbz2:file:///tmp/a.tbz2!/proj"));
+    
assertTrue(ProjectConfig.isArchiveUri("jar:zip:outer.zip!/nested.jar!/dir"));
+    
assertTrue(ProjectConfig.isArchiveUri("tar:gz:http://host/mytar.tar.gz!/mytar.tar!/path";));
+    assertTrue(ProjectConfig.isArchiveUri("  ZIP:/tmp/a.zip!/proj  "));
+
+    assertFalse(ProjectConfig.isArchiveUri(null));
+    assertFalse(ProjectConfig.isArchiveUri(""));
+    assertFalse(ProjectConfig.isArchiveUri("/home/user/project"));
+    assertFalse(ProjectConfig.isArchiveUri("file:///home/user/project"));
+    assertFalse(ProjectConfig.isArchiveUri("http://example.com/project";));
+    assertFalse(ProjectConfig.isArchiveUri("s3://bucket/project"));
+    // A path ending in .zip without an archive scheme is not an archive URI
+    assertFalse(ProjectConfig.isArchiveUri("/tmp/project.zip"));
+  }
+
+  @Test
+  public void testReadOnlyFlagDefaultsAndSetters() {
+    ProjectConfig config =
+        new ProjectConfig("demo", "/tmp/demo", 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    assertFalse(config.isReadOnly());
+    config.setReadOnly(true);
+    assertTrue(config.isReadOnly());
+  }
+
+  @Test
+  public void testGroupAndTagsHelpers() {
+    ProjectConfig config =
+        new ProjectConfig(
+            "demo", "/home/user/demo", 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    config.setGroup("Clients");
+    config.setTags(ProjectConfig.parseTags("etl, customer-a; legacy"));
+
+    assertEquals("Clients", config.getGroup());
+    assertEquals(3, config.getTags().size());
+    assertEquals("etl, customer-a, legacy", config.getTagsAsDisplayString());
+
+    assertTrue(config.matchesFilter("client"));
+    assertTrue(config.matchesFilter("ETL"));
+    assertTrue(config.matchesFilter("customer-a"));
+    assertTrue(config.matchesFilter("/home/user"));
+    assertFalse(config.matchesFilter("unrelated-xyz"));
+    assertTrue(config.matchesFilter(""));
+    assertTrue(config.matchesFilter(null));
+  }
+
+  @Test
+  public void testAddProjectConfigPreservesGroupTagsAndReadOnly() {
+    ProjectConfig config =
+        new ProjectConfig("meta", "/tmp/meta", 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    config.setGroup("Internal");
+    config.setTags(ProjectConfig.parseTags("poc"));
+    config.setReadOnly(true);
+    registerProject(config);
+
+    ProjectConfig update = new ProjectConfig("meta", "/tmp/meta2", 
"config/project-config.json");
+    update.setGroup("Clients");
+    update.setTags(ProjectConfig.parseTags("active, critical"));
+    update.setReadOnly(false);
+    ProjectsConfigSingleton.getConfig().addProjectConfig(update);
+
+    ProjectConfig stored = 
ProjectsConfigSingleton.getConfig().findProjectConfig("meta");
+    assertEquals("/tmp/meta2", stored.getProjectHome());
+    assertEquals("config/project-config.json", stored.getConfigFilename());
+    assertEquals("Clients", stored.getGroup());
+    assertEquals("active, critical", stored.getTagsAsDisplayString());
+    assertFalse(stored.isReadOnly());
+  }
+
+  @Test
+  public void testUpdateProjectConfigRename() {
+    ProjectConfig config =
+        new ProjectConfig("old-name", "/tmp/old", 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    config.setGroup("Legacy");
+    registerProject(config);
+
+    ProjectConfig renamed =
+        new ProjectConfig("new-name", "/tmp/new", 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    renamed.setGroup("Legacy");
+    renamed.setTags(ProjectConfig.parseTags("migrated"));
+    ProjectsConfigSingleton.getConfig().updateProjectConfig("old-name", 
renamed);
+    registeredProjectNames.remove("old-name");
+    registeredProjectNames.add("new-name");
+
+    
assertTrue(ProjectsConfigSingleton.getConfig().findProjectConfig("old-name") == 
null);
+    ProjectConfig stored = 
ProjectsConfigSingleton.getConfig().findProjectConfig("new-name");
+    assertEquals("new-name", stored.getProjectName());
+    assertEquals("/tmp/new", stored.getProjectHome());
+    assertEquals("migrated", stored.getTagsAsDisplayString());
+  }
+
+  @Test
+  public void testLoadProjectFromZipArchive() throws Exception {
+    tempRoot = Files.createTempDirectory("hop-project-zip");
+    Path zipFile = tempRoot.resolve("project.zip");
+
+    String configJson =
+        "{\n"
+            + "  \"description\" : \"zipped project\",\n"
+            + "  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+            + "  \"unitTestsBasePath\" : \"${PROJECT_HOME}\",\n"
+            + "  \"dataSetsCsvFolder\" : \"${PROJECT_HOME}/datasets\",\n"
+            + "  \"enforcingExecutionInHome\" : true,\n"
+            + "  \"parentProjectName\" : null,\n"
+            + "  \"config\" : {\n"
+            + "    \"variables\" : [ ]\n"
+            + "  }\n"
+            + "}\n";
+
+    try (ZipOutputStream zos = new 
ZipOutputStream(Files.newOutputStream(zipFile))) {
+      ZipEntry entry = new ZipEntry("my-project/" + 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+      zos.putNextEntry(entry);
+      zos.write(configJson.getBytes(StandardCharsets.UTF_8));
+      zos.closeEntry();
+    }
+
+    String homeUri = "zip:file://" + zipFile.toAbsolutePath() + "!/my-project";
+    ProjectConfig projectConfig =
+        new ProjectConfig("zipped", homeUri, 
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+    projectConfig.setReadOnly(true);
+    registerProject(projectConfig);
+
+    Project project = projectConfig.loadProject(new Variables());
+    assertEquals("zipped project", project.getDescription());
+    assertTrue(projectConfig.isReadOnly());
+  }
+
   private void registerProject(ProjectConfig projectConfig) {
     ProjectsConfigSingleton.getConfig().addProjectConfig(projectConfig);
     registeredProjectNames.add(projectConfig.getProjectName());
diff --git a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
index 76cd073df0..acdaa075b9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
@@ -135,6 +135,7 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
 
   public static final String BROWSER_TOOLBAR_PARENT_ID = 
"HopVfsFileDialog-BrowserToolbar";
   private static final String BROWSER_ITEM_ID_CREATE_FOLDER = 
"0020-create-folder";
+  private static final String BROWSER_ITEM_ID_DRILL_INTO = "0030-drill-into";
   private static final String BROWSER_ITEM_ID_SHOW_HIDDEN = "0200-show-hidden";
   private static final String BROWSER_ITEM_ID_DELETE = "0100-delete";
   private static final String BROWSER_ITEM_ID_RENAME = "0110-rename";
@@ -704,6 +705,17 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
         return;
       }
 
+      // Directory browser: an archive is not a valid folder selection — enter 
it instead so the
+      // user can pick a folder inside (zip:…!/path).
+      //
+      if (this.browsingDirectories && isDrillableArchive(activeFileObject)) {
+        String archiveUri = buildArchiveBrowseUri(activeFileObject);
+        if (archiveUri != null) {
+          navigateTo(archiveUri, true);
+          return;
+        }
+      }
+
       ok();
     } catch (Exception e) {
       showError(
@@ -851,15 +863,26 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     }
 
     try {
-      navigateTo(HopVfs.getFilename(fileObject), true);
-
       if (fileObject.isFolder()) {
         // Browse into the selected folder...
         //
-        refreshBrowser();
-      } else {
+        navigateTo(HopVfs.getFilename(fileObject), true);
+        return;
+      }
+
+      // Double-click drills into supported archives (zip/jar/tar/...). To 
select the archive
+      // itself as a file, select it and press OK.
+      //
+      String archiveUri = buildArchiveBrowseUri(fileObject);
+      if (archiveUri != null) {
+        navigateTo(archiveUri, true);
+        return;
+      }
+
+      if (!browsingDirectories) {
         // Take this file as the user choice for this dialog
         //
+        navigateTo(HopVfs.getFilename(fileObject), true);
         okButton();
       }
     } catch (Exception e) {
@@ -1028,7 +1051,16 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
         fileObjectsMap.put(getTreeItemPath(childFolderItem), child);
       }
     }
-    if (!browsingDirectories) {
+    if (browsingDirectories) {
+      // Directory mode: still show drillable archives so users can open 
project homes inside zip
+      // (and similar) via VFS without typing URIs by hand.
+      //
+      for (final FileObject child : children) {
+        if (child.isFile() && isDrillableArchive(child)) {
+          addBrowserFileItem(folderItem, child);
+        }
+      }
+    } else {
       for (final FileObject child : children) {
         if (child.isFile()) {
           String baseFilename = child.getName().getBaseName();
@@ -1051,33 +1083,59 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
             }
           }
 
-          // Hidden file?
+          // Always offer drillable archives even when the active filter would 
hide them
+          // (e.g. "Pipelines" only), so nested zip/jar browsing stays 
available.
           //
-          if (selectFile) {
-            TreeItem childFileItem = new TreeItem(folderItem, SWT.NONE);
-            childFileItem.setImage(getFileImage(child));
-            childFileItem.setFont(GuiResource.getInstance().getFontBold());
-            childFileItem.setText(0, child.getName().getBaseName());
-            childFileItem.setText(1, getFileDate(child));
-            childFileItem.setText(2, getFileSize(child));
-            fileObjectsMap.put(getTreeItemPath(childFileItem), child);
-
-            // Gray out if the file is not readable
-            //
-            if (!child.isReadable()) {
-              
childFileItem.setForeground(GuiResource.getInstance().getColorGray());
-            }
+          if (!selectFile && isDrillableArchive(child)) {
+            selectFile = true;
+          }
 
-            if (child.equals(activeFileObject)) {
-              wBrowser.setSelection(childFileItem);
-              wBrowser.showSelection();
-            }
+          if (selectFile) {
+            addBrowserFileItem(folderItem, child);
           }
         }
       }
     }
   }
 
+  /**
+   * Add a file row to the browser tree (name, modified, size, image, 
selection map).
+   *
+   * @param folderItem parent tree item for the current folder
+   * @param child file to display
+   */
+  private void addBrowserFileItem(TreeItem folderItem, FileObject child)
+      throws FileSystemException {
+    String baseFilename = child.getName().getBaseName();
+    if (!showingHiddenFiles && baseFilename.startsWith(".")) {
+      return;
+    }
+    if (shouldFilterOut(baseFilename)) {
+      return;
+    }
+
+    TreeItem childFileItem = new TreeItem(folderItem, SWT.NONE);
+    childFileItem.setImage(getFileImage(child));
+    childFileItem.setFont(GuiResource.getInstance().getFontBold());
+    childFileItem.setText(0, baseFilename);
+    childFileItem.setText(1, getFileDate(child));
+    childFileItem.setText(2, getFileSize(child));
+    fileObjectsMap.put(getTreeItemPath(childFileItem), child);
+
+    if (!child.isReadable()) {
+      childFileItem.setForeground(GuiResource.getInstance().getColorGray());
+    }
+
+    if (child.equals(activeFileObject)) {
+      wBrowser.setSelection(childFileItem);
+      wBrowser.showSelection();
+    }
+  }
+
+  private static boolean isDrillableArchive(FileObject file) throws 
FileSystemException {
+    return file != null && file.isFile() && 
getArchiveScheme(file.getName().getBaseName()) != null;
+  }
+
   private static int compareBaseName(FileObject child1, FileObject child2) {
     try {
       String name1 = child1.getName().getBaseName();
@@ -1417,6 +1475,114 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     }
   }
 
+  @GuiToolbarElement(
+      root = BROWSER_TOOLBAR_PARENT_ID,
+      id = BROWSER_ITEM_ID_DRILL_INTO,
+      toolTip = "i18n::HopVfsFileDialog.DrillInto.Tooltip.Message",
+      image = "ui/images/zipfile.svg")
+  public void drillIntoArchive() {
+    FileObject file = getSelectedFileObject();
+    if (file == null) {
+      return;
+    }
+    try {
+      String archiveUri = buildArchiveBrowseUri(file);
+      if (archiveUri == null) {
+        return;
+      }
+      navigateTo(archiveUri, true);
+    } catch (Exception e) {
+      showError(
+          BaseMessages.getString(PKG, 
"HopVfsFileDialog.DrillInto.Error.Message", file.toString()),
+          e);
+    }
+  }
+
+  /**
+   * Map a file name or path to a Commons VFS archive scheme that supports 
browsing with {@code !/}.
+   *
+   * @param nameOrPath file name, path, or URI
+   * @return scheme such as {@code zip}, {@code jar}, {@code tar}, {@code 
tgz}, {@code tbz2}, or
+   *     {@code null} if the file is not a drillable archive
+   */
+  static String getArchiveScheme(String nameOrPath) {
+    if (StringUtils.isEmpty(nameOrPath)) {
+      return null;
+    }
+    String base = nameOrPath;
+    int bang = base.lastIndexOf('!');
+    if (bang >= 0) {
+      base = base.substring(bang + 1);
+    }
+    int slash = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'));
+    if (slash >= 0) {
+      base = base.substring(slash + 1);
+    }
+    String lower = base.toLowerCase();
+    // Compound suffixes before short ones (.tar.gz before .gz)
+    if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) {
+      return "tgz";
+    }
+    if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) {
+      return "tbz2";
+    }
+    if (lower.endsWith(".tar")) {
+      return "tar";
+    }
+    if (lower.endsWith(".zip")) {
+      return "zip";
+    }
+    if (lower.endsWith(".jar")
+        || lower.endsWith(".war")
+        || lower.endsWith(".ear")
+        || lower.endsWith(".par")
+        || lower.endsWith(".sar")
+        || lower.endsWith(".ejb3")) {
+      return "jar";
+    }
+    return null;
+  }
+
+  /**
+   * Build a VFS URI that opens an archive for browsing ({@code 
scheme:archFileUri!/}).
+   *
+   * @param scheme VFS archive scheme (zip, jar, tar, tgz, tbz2)
+   * @param archFileUri path or URI of the archive file
+   * @return browse URI, or {@code null} if inputs are incomplete
+   */
+  static String buildArchiveBrowseUri(String scheme, String archFileUri) {
+    if (StringUtils.isEmpty(scheme) || StringUtils.isEmpty(archFileUri)) {
+      return null;
+    }
+    return scheme + ":" + archFileUri + "!/";
+  }
+
+  /**
+   * Build a VFS URI that opens the given archive for browsing (scheme + 
archive + {@code !/}).
+   *
+   * @param file archive file object
+   * @return browse URI, or {@code null} if not a supported archive
+   */
+  static String buildArchiveBrowseUri(FileObject file) {
+    if (file == null) {
+      return null;
+    }
+    String scheme = getArchiveScheme(file.getName().getBaseName());
+    if (scheme == null) {
+      return null;
+    }
+    String archFileUri;
+    String root = file.getName().getRootURI();
+    if (root != null && root.startsWith("file:")) {
+      // Local files: match Hop issue style zip:/path/to/archive.zip!/
+      archFileUri = HopVfs.getFilename(file);
+    } else {
+      // Nested / remote: keep full URI (e.g. jar:zip:...!/nested.jar!/)
+      archFileUri = file.getName().getURI();
+    }
+    return buildArchiveBrowseUri(scheme, archFileUri);
+  }
+
   @GuiToolbarElement(
       root = BROWSER_TOOLBAR_PARENT_ID,
       id = BROWSER_ITEM_ID_RENAME,
@@ -1665,10 +1831,14 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
     FileObject file = getSelectedFileObject();
 
     boolean isEnabled = false;
+    boolean canDrillInto = false;
     if (file != null) {
       try {
         // Protect root can be modified
-        if (file.getParent() != null) isEnabled = true;
+        if (file.getParent() != null) {
+          isEnabled = true;
+        }
+        canDrillInto = isDrillableArchive(file);
       } catch (FileSystemException e) {
         // Ignore
       }
@@ -1676,5 +1846,6 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
 
     browserToolbarWidgets.enableToolbarItem(BROWSER_ITEM_ID_DELETE, isEnabled);
     browserToolbarWidgets.enableToolbarItem(BROWSER_ITEM_ID_RENAME, isEnabled);
+    browserToolbarWidgets.enableToolbarItem(BROWSER_ITEM_ID_DRILL_INTO, 
canDrillInto);
   }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
index de7150104d..c4854ab623 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
@@ -20,6 +20,7 @@ package org.apache.hop.ui.core.widget;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
@@ -60,8 +61,16 @@ public class FileTree extends Composite {
   private final AtomicBoolean findDependencies;
   private static final String FILE_OBJECT = "fileObject";
   private static final String FOLDER = "folder";
+  private static final String FILE = "file";
   private final Tree tree;
 
+  /**
+   * Guard against re-entrant SWT CHECK events. Programmatic {@link 
TreeItem#setChecked(boolean)}
+   * can fire nested selection events on some platforms (notably GTK); those 
must not undo a cascade
+   * that is already in progress.
+   */
+  private boolean ignoreCheckEvents;
+
   public FileTree(Composite composite, FileObject rootFolder, String 
rootFolderName)
       throws FileSystemException {
     super(composite, SWT.NONE);
@@ -100,23 +109,54 @@ public class FileTree extends Composite {
     tree.addListener(
         SWT.Selection,
         event -> {
-          if (event.detail == SWT.CHECK) {
-            TreeItem[] selectedTreeItems = tree.getSelection();
-            TreeItem item = (TreeItem) event.item;
-            boolean checked = item.getChecked();
-            if (selectedTreeItems.length > 0) {
-              for (TreeItem treeItem : selectedTreeItems) {
-                if (!treeItem.getChecked() == checked) {
-                  selectionEvent(rootItem, treeItem, checked);
-                }
+          if (event.detail != SWT.CHECK || ignoreCheckEvents) {
+            return;
+          }
+          TreeItem item = (TreeItem) event.item;
+          if (item == null || item.isDisposed()) {
+            return;
+          }
+          boolean checked = item.getChecked();
+          // Snapshot multi-selection before we change checkboxes / deselect.
+          TreeItem[] multiSelection = tree.getSelection();
+
+          ignoreCheckEvents = true;
+          try {
+            // Always process the item that was checked/unchecked so folder 
selection
+            // propagates to all children.
+            selectionEvent(rootItem, item, checked);
+            // Also apply the same state to any other multi-selected items.
+            for (TreeItem treeItem : multiSelection) {
+              if (treeItem != item && !treeItem.isDisposed()) {
+                treeItem.setChecked(checked);
+                selectionEvent(rootItem, treeItem, checked);
               }
-            } else {
-              selectionEvent(rootItem, item, checked);
             }
             tree.deselectAll();
+          } finally {
+            ignoreCheckEvents = false;
           }
         });
-    rootItem.setExpanded(true);
+
+    // Select everything by default so export works without manual checkbox 
interaction.
+    // Guard ignoreCheckEvents so programmatic setChecked does not fire nested 
handlers.
+    ignoreCheckEvents = true;
+    try {
+      cascadeCheck(rootItem, true);
+    } finally {
+      ignoreCheckEvents = false;
+    }
+    expandAll(rootItem);
+  }
+
+  /** Expand the given item and all descendants so the default selection is 
visible. */
+  private void expandAll(TreeItem item) {
+    item.setExpanded(true);
+    for (TreeItem child : item.getItems()) {
+      if (FOLDER.equals(child.getData("type"))) {
+        expandAll(child);
+      }
+    }
   }
 
   /**
@@ -127,9 +167,14 @@ public class FileTree extends Composite {
    * @param checked true when enabling a checkbox, false when disabling
    */
   private void selectionEvent(TreeItem rootItem, TreeItem treeItem, boolean 
checked) {
-    if (treeItem.getData("type").equals(FOLDER)) {
-      checkItems(rootItem, treeItem, checked);
-      checkGrayedItems(treeItem);
+    Object type = treeItem.getData("type");
+    if (FOLDER.equals(type)) {
+      // Cascade to every descendant. Do not resolve pipeline/workflow 
dependencies while
+      // cascading: every file under the folder is included, and dependency 
resolution would
+      // re-enter checkbox updates and can undo the cascade (empty export).
+      cascadeCheck(treeItem, checked);
+      // Update grayed/checked state of ancestors only — cascadeCheck already 
set this folder.
+      checkGrayedItems(treeItem.getParentItem());
     } else {
       FileObject fileObject = (FileObject) treeItem.getData(FILE_OBJECT);
       if (findDependencies.get()) {
@@ -137,9 +182,11 @@ public class FileTree extends Composite {
       }
       if (checked) {
         fileObjects.add(fileObject);
+        treeItem.setGrayed(false);
         treeItem.setChecked(true);
       } else {
         fileObjects.remove(fileObject);
+        treeItem.setGrayed(false);
         treeItem.setChecked(false);
       }
       checkGrayedItems(treeItem.getParentItem());
@@ -156,7 +203,10 @@ public class FileTree extends Composite {
    */
   private void populateFolder(FileObject folder, TreeItem folderItem) throws 
FileSystemException {
     FileObject[] children = folder.getChildren();
-    Arrays.sort(children);
+    Arrays.sort(
+        children,
+        Comparator.comparing(
+            child -> child.getName().getBaseName(), 
String.CASE_INSENSITIVE_ORDER));
 
     // Add the folders
     for (FileObject child : children) {
@@ -184,7 +234,7 @@ public class FileTree extends Composite {
         TreeItem childItem = new TreeItem(folderItem, SWT.NONE);
         childItem.setImage(GuiResource.getInstance().getImageFile());
         childItem.setText(child.getName().getBaseName());
-        childItem.setData("type", "file");
+        childItem.setData("type", FILE);
         childItem.setData(FILE_OBJECT, child);
       }
     }
@@ -213,56 +263,56 @@ public class FileTree extends Composite {
   }
 
   /**
-   * Scan the Tree to find the TreeItem to select/deselect
+   * Recursively set the checked state of a folder and all descendants, and 
update {@link
+   * #fileObjects} for every file under that folder.
    *
-   * @param rootItem the main tree to update
-   * @param item that needs to be enabled/disabled
-   * @param checked true when enabled, false when disabled
+   * @param item folder or file tree item
+   * @param checked true when enabling a checkbox, false when disabling
    */
-  void checkItems(TreeItem rootItem, TreeItem item, boolean checked) {
+  void cascadeCheck(TreeItem item, boolean checked) {
     item.setGrayed(false);
     item.setChecked(checked);
-    TreeItem[] items = item.getItems();
-    for (TreeItem treeItem : items) {
-      if (treeItem.getData("type").equals("file")) {
-        FileObject fileObject = (FileObject) treeItem.getData(FILE_OBJECT);
-        if (findDependencies.get() && item != rootItem) {
-          addDependencies(fileObject, rootItem, checked);
-        }
+    for (TreeItem child : item.getItems()) {
+      Object type = child.getData("type");
+      if (FILE.equals(type)) {
+        FileObject fileObject = (FileObject) child.getData(FILE_OBJECT);
         if (checked) {
           fileObjects.add(fileObject);
         } else {
           fileObjects.remove(fileObject);
         }
       }
-      checkItems(rootItem, treeItem, checked);
+      cascadeCheck(child, checked);
     }
   }
 
   /**
-   * Set Grayed to checkboxes to the parents of the current item
+   * Set Grayed to checkboxes on the given folder and its ancestors, based on 
child state.
    *
-   * @param parentItem The start folder to start checking
+   * @param parentItem The start folder to start checking (typically the 
parent of the changed item)
    */
   void checkGrayedItems(TreeItem parentItem) {
-    if (parentItem == null) {
+    if (parentItem == null || parentItem.isDisposed()) {
       return;
     }
     TreeItem[] items = parentItem.getItems();
-    boolean allChecked = true;
+    boolean allChecked = items.length > 0;
     boolean atLeastOneChecked = false;
     for (TreeItem treeItem : items) {
-      if (!treeItem.getChecked() || (treeItem.getChecked() && 
treeItem.getGrayed())) {
+      if (!treeItem.getChecked() || treeItem.getGrayed()) {
         allChecked = false;
       }
       atLeastOneChecked = treeItem.getChecked() || atLeastOneChecked;
     }
     if (allChecked) {
       parentItem.setGrayed(false);
-      parentItem.setChecked(atLeastOneChecked);
-    } else {
+      parentItem.setChecked(true);
+    } else if (atLeastOneChecked) {
       parentItem.setGrayed(true);
-      parentItem.setChecked(atLeastOneChecked);
+      parentItem.setChecked(true);
+    } else {
+      parentItem.setGrayed(false);
+      parentItem.setChecked(false);
     }
     checkGrayedItems(parentItem.getParentItem());
   }
@@ -370,13 +420,19 @@ public class FileTree extends Composite {
   void checkItemsByFileObject(TreeItem rootItem, String file, boolean checked) 
{
     TreeItem[] children = rootItem.getItems();
     for (TreeItem treeItem : children) {
-      if (treeItem.getData("type").equals(FOLDER)) {
+      Object type = treeItem.getData("type");
+      if (FOLDER.equals(type)) {
         checkItemsByFileObject(treeItem, file, checked);
       }
-      if (treeItem.getData("type").equals("file")
+      if (FILE.equals(type)
           && ((FileObject) 
treeItem.getData(FILE_OBJECT)).getName().getURI().equals(file)) {
         treeItem.setGrayed(false);
         treeItem.setChecked(checked);
+        if (checked) {
+          fileObjects.add((FileObject) treeItem.getData(FILE_OBJECT));
+        } else {
+          fileObjects.remove((FileObject) treeItem.getData(FILE_OBJECT));
+        }
         checkGrayedItems(treeItem.getParentItem());
       }
     }
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
index 00d73370a0..328bad2bbc 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/vfs/messages/messages_en_US.properties
@@ -30,6 +30,8 @@ HopVfsFileDialog.DeleteFile.Confirmation.Header=Delete file?
 HopVfsFileDialog.DeleteFile.Confirmation.Message=Are you sure you want to 
delete the following file?
 HopVfsFileDialog.DeleteFile.Error.Message=Error deleting file: ''{0}''
 HopVfsFileDialog.DeleteFile.Tooltip.Message=Delete selected file (DEL)
+HopVfsFileDialog.DrillInto.Error.Message=Error drilling into archive: ''{0}''
+HopVfsFileDialog.DrillInto.Tooltip.Message=Drill into the selected archive 
(zip, jar, tar, …). Double-click does the same; use OK to select the archive 
file itself.
 HopVfsFileDialog.File.Size.Label=Size
 HopVfsFileDialog.FileInfo.Tooltip.Attributes=Attributes: {0}
 HopVfsFileDialog.FileInfo.Tooltip.Executable=Executable: {0}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogArchiveTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogArchiveTest.java
new file mode 100644
index 0000000000..a7d17215c6
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogArchiveTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
+import org.junit.jupiter.api.Test;
+
+class HopVfsFileDialogArchiveTest {
+
+  @Test
+  void getArchiveSchemeRecognizesSupportedExtensions() {
+    assertEquals("zip", HopVfsFileDialog.getArchiveScheme("archive.zip"));
+    assertEquals("zip", 
HopVfsFileDialog.getArchiveScheme("/home/matt/Downloads/archive.ZIP"));
+    assertEquals("jar", HopVfsFileDialog.getArchiveScheme("lib.jar"));
+    assertEquals("jar", HopVfsFileDialog.getArchiveScheme("app.war"));
+    assertEquals("jar", HopVfsFileDialog.getArchiveScheme("module.ear"));
+    assertEquals("tar", HopVfsFileDialog.getArchiveScheme("data.tar"));
+    assertEquals("tgz", HopVfsFileDialog.getArchiveScheme("data.tgz"));
+    assertEquals("tgz", HopVfsFileDialog.getArchiveScheme("data.tar.gz"));
+    assertEquals("tbz2", HopVfsFileDialog.getArchiveScheme("data.tbz2"));
+    assertEquals("tbz2", HopVfsFileDialog.getArchiveScheme("data.tar.bz2"));
+  }
+
+  @Test
+  void getArchiveSchemeUsesBasenameFromPathAndNestedUri() {
+    assertEquals("zip", 
HopVfsFileDialog.getArchiveScheme("/tmp/folder/file.zip"));
+    assertEquals("jar", 
HopVfsFileDialog.getArchiveScheme("zip:file:///tmp/outer.zip!/nested.jar"));
+  }
+
+  @Test
+  void getArchiveSchemeRejectsNonDrillableFiles() {
+    assertNull(HopVfsFileDialog.getArchiveScheme(null));
+    assertNull(HopVfsFileDialog.getArchiveScheme(""));
+    assertNull(HopVfsFileDialog.getArchiveScheme("readme.txt"));
+    assertNull(HopVfsFileDialog.getArchiveScheme("data.gz"));
+    assertNull(HopVfsFileDialog.getArchiveScheme("data.bz2"));
+    assertNull(HopVfsFileDialog.getArchiveScheme("archive.7z"));
+    assertNull(HopVfsFileDialog.getArchiveScheme("archive.rar"));
+  }
+
+  @Test
+  void buildArchiveBrowseUriFromSchemeAndPath() {
+    assertEquals(
+        "zip:/home/matt/Downloads/archive.zip!/",
+        HopVfsFileDialog.buildArchiveBrowseUri("zip", 
"/home/matt/Downloads/archive.zip"));
+    assertEquals(
+        "jar:zip:file:///tmp/outer.zip!/nested.jar!/",
+        HopVfsFileDialog.buildArchiveBrowseUri("jar", 
"zip:file:///tmp/outer.zip!/nested.jar"));
+    assertNull(HopVfsFileDialog.buildArchiveBrowseUri(null, "/tmp/a.zip"));
+    assertNull(HopVfsFileDialog.buildArchiveBrowseUri("zip", null));
+    assertNull(HopVfsFileDialog.buildArchiveBrowseUri("", "/tmp/a.zip"));
+  }
+
+  @Test
+  void buildArchiveBrowseUriFromNestedFileObject() {
+    FileObject nestedJar = mock(FileObject.class);
+    FileName name = mock(FileName.class);
+    when(nestedJar.getName()).thenReturn(name);
+    when(name.getBaseName()).thenReturn("nested.jar");
+    when(name.getRootURI()).thenReturn("zip:file:///tmp/outer.zip!/");
+    when(name.getURI()).thenReturn("zip:file:///tmp/outer.zip!/nested.jar");
+
+    assertEquals(
+        "jar:zip:file:///tmp/outer.zip!/nested.jar!/",
+        HopVfsFileDialog.buildArchiveBrowseUri(nestedJar));
+  }
+
+  @Test
+  void buildArchiveBrowseUriRejectsNonArchives() {
+    assertNull(HopVfsFileDialog.buildArchiveBrowseUri(null));
+
+    FileObject textFile = mock(FileObject.class);
+    FileName name = mock(FileName.class);
+    when(textFile.getName()).thenReturn(name);
+    when(name.getBaseName()).thenReturn("readme.txt");
+    when(name.getRootURI()).thenReturn("file:///");
+
+    assertNull(HopVfsFileDialog.buildArchiveBrowseUri(textFile));
+  }
+}

Reply via email to