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 0c924986e0 issue #7783: speed up explorer filter and large project 
tree open (#7784)
0c924986e0 is described below

commit 0c924986e079f606117a7fe58087d06ae0ba36d5
Author: Matt Casters <[email protected]>
AuthorDate: Thu Aug 6 09:06:22 2026 +0200

    issue #7783: speed up explorer filter and large project tree open (#7784)
    
    Debounce the file explorer filter, rebuild filtered trees from an
    in-memory index without re-running git status per keystroke, and remove
    the recursive hasMatchingDescendant VFS probes. Make file-type detection
    string/extension-map based so opening a project with thousands of files
    no longer performs a VFS open per plugin per entry.
---
 .../main/java/org/apache/hop/git/GitGuiPlugin.java |  44 +-
 .../perspective/explorer/file/ParquetFileType.java |  31 +-
 .../perspective/explorer/file/ExcelFileType.java   |  31 +-
 .../apache/hop/ui/hopgui/file/HopFileTypeBase.java |  86 ++-
 .../perspective/explorer/ExplorerPerspective.java  | 643 +++++++++++++++------
 .../perspective/explorer/ExplorerTreeModel.java    | 118 ++++
 .../explorer/file/types/ArchiveFileType.java       |  31 +-
 .../explorer/file/types/FolderFileType.java        |  17 +-
 .../explorer/file/types/GenericFileType.java       |  16 +-
 .../types/noext/NoExtensionExplorerFileType.java   |   7 +-
 .../hop/ui/hopgui/file/HopFileTypeBaseTest.java    | 139 +++++
 .../explorer/ExplorerTreeModelTest.java            | 101 ++++
 12 files changed, 951 insertions(+), 313 deletions(-)

diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
index ba9e8480e6..4aac3ca64d 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitGuiPlugin.java
@@ -771,35 +771,51 @@ public class GitGuiPlugin
   }
 
   /**
-   * Normalize absolute filename.
+   * Normalize a path for map keys used by {@link #filePainted}. Pure string 
normalization — must
+   * not open VFS objects (called once per explorer tree item).
    *
    * @param path the path to normalize
-   * @return normalized path
+   * @return normalized path (forward slashes, no trailing slash except root)
    */
   private String getAbsoluteFilename(String path) {
-    try {
-      path = HopVfs.getFileObject(path).getName().getPath();
-    } catch (Exception e) {
-      // Ignore, keep simple path
+    if (path == null) {
+      return null;
+    }
+    String normalized = path.replace('\\', '/');
+    // Strip file: URI prefix when present so keys match explorer OS paths 
after conversion
+    if (normalized.startsWith("file://")) {
+      normalized = normalized.substring("file://".length());
+      // file:///C:/... → /C:/... on some platforms; leave as-is and rely on 
slash unify
+    }
+    while (normalized.contains("//")) {
+      normalized = normalized.replace("//", "/");
     }
-    return path;
+    if (normalized.length() > 1 && normalized.endsWith("/")) {
+      normalized = normalized.substring(0, normalized.length() - 1);
+    }
+    return normalized;
   }
 
   /**
-   * Normalize absolute filename
+   * Normalize absolute filename from a git-relative path without VFS.
    *
    * @param root The root path
    * @param relativePath The relative path
    * @return The absolute filename
    */
   private String getAbsoluteFilename(String root, String relativePath) {
-    String path = root + File.separator + relativePath;
-    try {
-      path = HopVfs.getFileObject(path).getName().getPath();
-    } catch (Exception e) {
-      // Ignore, keep simple path
+    if (relativePath == null || relativePath.isEmpty()) {
+      return getAbsoluteFilename(root);
+    }
+    String rel = relativePath.replace('\\', '/');
+    while (rel.startsWith("./")) {
+      rel = rel.substring(2);
+    }
+    String base = root == null ? "" : root.replace('\\', '/');
+    if (base.endsWith("/")) {
+      return getAbsoluteFilename(base + rel);
     }
-    return path;
+    return getAbsoluteFilename(base + "/" + rel);
   }
 
   /* package*/ void refreshChangedFiles() {
diff --git 
a/plugins/tech/parquet/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ParquetFileType.java
 
b/plugins/tech/parquet/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ParquetFileType.java
index 09f7cbe5e2..39b06328a8 100644
--- 
a/plugins/tech/parquet/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ParquetFileType.java
+++ 
b/plugins/tech/parquet/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ParquetFileType.java
@@ -20,16 +20,15 @@ package org.apache.hop.ui.hopgui.perspective.explorer.file;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Properties;
-import org.apache.commons.vfs2.FileName;
-import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.file.IHasFilename;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
 import org.apache.hop.ui.hopgui.file.HopFileTypePlugin;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
@@ -89,26 +88,16 @@ public class ParquetFileType implements IHopFileType {
 
   @Override
   public boolean isHandledBy(String filename, boolean checkContent) throws 
HopException {
-    try {
-      FileObject fileObject = HopVfs.getFileObject(filename);
-      FileName fileName = fileObject.getName();
-      String fileExtension = fileName.getExtension().toLowerCase();
-
-      // No extension
-      if (Utils.isEmpty(fileExtension)) return false;
-
-      // Verify the extension
-      //
-      for (String typeExtension : EXTENSIONS) {
-        if (typeExtension.toLowerCase().endsWith(fileExtension)) {
-          return true;
-        }
-      }
+    String fileExtension = HopFileTypeBase.extractExtension(filename);
+    if (Utils.isEmpty(fileExtension)) {
       return false;
-    } catch (Exception e) {
-      throw new HopException(
-          "Unable to verify file handling of file '" + filename + "' by 
extension", e);
     }
+    for (String typeExtension : EXTENSIONS) {
+      if (typeExtension.toLowerCase(Locale.ROOT).endsWith(fileExtension)) {
+        return true;
+      }
+    }
+    return false;
   }
 
   @Override
diff --git 
a/plugins/transforms/excel/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ExcelFileType.java
 
b/plugins/transforms/excel/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ExcelFileType.java
index cf0c617caa..a34049cf53 100644
--- 
a/plugins/transforms/excel/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ExcelFileType.java
+++ 
b/plugins/transforms/excel/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/ExcelFileType.java
@@ -20,16 +20,15 @@ package org.apache.hop.ui.hopgui.perspective.explorer.file;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Properties;
-import org.apache.commons.vfs2.FileName;
-import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.file.IHasFilename;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
 import org.apache.hop.ui.hopgui.file.HopFileTypePlugin;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
@@ -89,26 +88,16 @@ public class ExcelFileType implements IHopFileType {
 
   @Override
   public boolean isHandledBy(String filename, boolean checkContent) throws 
HopException {
-    try {
-      FileObject fileObject = HopVfs.getFileObject(filename);
-      FileName fileName = fileObject.getName();
-      String fileExtension = fileName.getExtension().toLowerCase();
-
-      // No extension
-      if (Utils.isEmpty(fileExtension)) return false;
-
-      // Verify the extension
-      //
-      for (String typeExtension : EXTENSIONS) {
-        if (typeExtension.toLowerCase().endsWith(fileExtension)) {
-          return true;
-        }
-      }
+    String fileExtension = HopFileTypeBase.extractExtension(filename);
+    if (Utils.isEmpty(fileExtension)) {
       return false;
-    } catch (Exception e) {
-      throw new HopException(
-          "Unable to verify file handling of file '" + filename + "' by 
extension", e);
     }
+    for (String typeExtension : EXTENSIONS) {
+      if (typeExtension.toLowerCase(Locale.ROOT).endsWith(fileExtension)) {
+        return true;
+      }
+    }
+    return false;
   }
 
   @Override
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeBase.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeBase.java
index b5d2a6ab36..c3b40764ad 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeBase.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeBase.java
@@ -17,11 +17,9 @@
 
 package org.apache.hop.ui.hopgui.file;
 
-import org.apache.commons.vfs2.FileName;
-import org.apache.commons.vfs2.FileObject;
+import java.util.Locale;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.Utils;
-import org.apache.hop.core.vfs.HopVfs;
 
 public abstract class HopFileTypeBase implements IHopFileType {
 
@@ -31,7 +29,7 @@ public abstract class HopFileTypeBase implements IHopFileType 
{
       return true;
     }
     // same class is enough
-    return obj.getClass().equals(this.getClass());
+    return obj != null && obj.getClass().equals(this.getClass());
   }
 
   @Override
@@ -43,29 +41,87 @@ public abstract class HopFileTypeBase implements 
IHopFileType {
                 + filename
                 + "'");
       } else {
-        FileObject fileObject = HopVfs.getFileObject(filename);
-        FileName fileName = fileObject.getName();
-        String fileExtension = fileName.getExtension().toLowerCase();
-
-        // No extension
-        if (Utils.isEmpty(fileExtension)) return false;
+        // Pure string matching — do not open the path via VFS (explorer lists 
thousands of files).
+        String fileExtension = extractExtension(filename);
+        if (Utils.isEmpty(fileExtension)) {
+          return false;
+        }
 
-        // Verify the extension
-        //
-        for (String typeExtension : getFilterExtensions()) {
-          if (typeExtension.toLowerCase().endsWith(fileExtension)) {
-            return true;
+        String[] filters = getFilterExtensions();
+        if (filters == null) {
+          return false;
+        }
+        for (String typeExtension : filters) {
+          if (Utils.isEmpty(typeExtension)) {
+            continue;
+          }
+          // Support compound filters like "*.xlsx;*.xls"
+          for (String part : typeExtension.split(";")) {
+            String normalized = part.trim().toLowerCase(Locale.ROOT);
+            if (normalized.endsWith(fileExtension)) {
+              return true;
+            }
           }
         }
 
         return false;
       }
+    } catch (HopException e) {
+      throw e;
     } catch (Exception e) {
       throw new HopException(
           "Unable to verify file handling of file '" + filename + "' by 
extension", e);
     }
   }
 
+  /**
+   * Extract the file extension from a path or URI without touching VFS. 
Returns lower-case
+   * extension without the dot, or empty string when there is none (including 
names like {@code
+   * .gitignore} where the only dot is the leading one).
+   */
+  public static String extractExtension(String filename) {
+    if (Utils.isEmpty(filename)) {
+      return "";
+    }
+    String name = filename;
+    int query = name.indexOf('?');
+    if (query >= 0) {
+      name = name.substring(0, query);
+    }
+    int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
+    if (slash >= 0 && slash < name.length() - 1) {
+      name = name.substring(slash + 1);
+    } else if (slash >= 0) {
+      return "";
+    }
+    int dot = name.lastIndexOf('.');
+    // dot at 0 is a hidden file without a real extension (e.g. .gitignore)
+    if (dot <= 0 || dot >= name.length() - 1) {
+      return "";
+    }
+    return name.substring(dot + 1).toLowerCase(Locale.ROOT);
+  }
+
+  /** Base name (last path segment) without VFS. */
+  public static String extractBaseName(String filename) {
+    if (Utils.isEmpty(filename)) {
+      return "";
+    }
+    String name = filename;
+    int query = name.indexOf('?');
+    if (query >= 0) {
+      name = name.substring(0, query);
+    }
+    int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
+    if (slash >= 0 && slash < name.length() - 1) {
+      return name.substring(slash + 1);
+    }
+    if (slash >= 0) {
+      return "";
+    }
+    return name;
+  }
+
   @Override
   public boolean hasCapability(String capability) {
     if (getCapabilities() == null) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index eba5a019ba..3dfe044525 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -29,6 +29,7 @@ import java.util.Comparator;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.function.Function;
@@ -97,6 +98,7 @@ import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
 import org.apache.hop.ui.hopgui.HopWebUrlHelper;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
 import org.apache.hop.ui.hopgui.file.HopFileTypePluginType;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
@@ -156,6 +158,7 @@ import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Menu;
@@ -296,12 +299,33 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   @Getter private List<IExplorerRefreshListener> refreshListeners;
   @Getter private List<IExplorerSelectionListener> selectionListeners;
   private List<IHopFileType> fileTypes;
+
+  /**
+   * Lower-case extension (no dot) → first matching file type; built once in 
{@link #loadFileTypes}.
+   */
+  private Map<String, IHopFileType> fileTypeByExtension = new HashMap<>();
+
+  /** Exact base name → type for filters that are not extension patterns (e.g. 
Dockerfile). */
+  private Map<String, IHopFileType> fileTypeByBaseName = new HashMap<>();
+
+  private IHopFileType folderFileType;
+  private IHopFileType genericFileType;
+  private final IHopFileType emptyFileType = new EmptyFileType();
   private Map<String, Image> typeImageMap;
   private Text searchText;
   private String filterText = "";
-  private SearchMatcher filterMatcher = new SearchMatcher("", false, false, 
true);
+
+  /** Substring matcher only — fuzzy scoring is too expensive for 
per-keystroke file filters. */
+  private SearchMatcher filterMatcher = new SearchMatcher("", false, false, 
false);
+
   private Map<String, Boolean> treeStateBeforeFilter = null;
 
+  /** In-memory project tree for fast filter re-applies without re-walking 
VFS. */
+  private final ExplorerTreeModel treeModel = new ExplorerTreeModel();
+
+  private final Runnable applyFilterRunnable = this::applyPendingFilter;
+  private static final int FILTER_DEBOUNCE_MS = 250;
+
   /** Last folder the user selected in the tree; used for scoped refresh. */
   private String lastSelectedFolderPath;
 
@@ -482,6 +506,63 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
     // Keep as last in the list...
     fileTypes.add(new GenericFileType());
+    indexFileTypes();
+  }
+
+  /**
+   * Build O(1) extension and basename maps so tree painting does not probe 
every file type (and
+   * historically every probe opened a VFS object) for each of thousands of 
project files.
+   */
+  private void indexFileTypes() {
+    fileTypeByExtension = new HashMap<>();
+    fileTypeByBaseName = new HashMap<>();
+    folderFileType = null;
+    genericFileType = null;
+
+    for (IHopFileType type : fileTypes) {
+      if (type instanceof FolderFileType) {
+        folderFileType = type;
+        continue;
+      }
+      if (type instanceof GenericFileType) {
+        genericFileType = type;
+        continue;
+      }
+
+      String[] filters = type.getFilterExtensions();
+      if (filters == null) {
+        continue;
+      }
+      for (String filter : filters) {
+        if (Utils.isEmpty(filter)) {
+          continue;
+        }
+        for (String part : filter.split(";")) {
+          String token = part.trim();
+          if (token.isEmpty() || "*".equals(token)) {
+            continue;
+          }
+          if (token.startsWith("*.") && token.length() > 2) {
+            String ext = token.substring(2).toLowerCase(Locale.ROOT);
+            fileTypeByExtension.putIfAbsent(ext, type);
+          } else if (token.startsWith(".") && token.length() > 1 && 
token.indexOf('.', 1) < 0) {
+            // ".gitignore" style token used as exact base name elsewhere — 
also as extension-less
+            // name match
+            fileTypeByBaseName.putIfAbsent(token, type);
+          } else if (!token.contains("*")) {
+            // Exact base name: Dockerfile, config, README, ...
+            fileTypeByBaseName.putIfAbsent(token, type);
+          }
+        }
+      }
+    }
+
+    if (folderFileType == null) {
+      folderFileType = new FolderFileType();
+    }
+    if (genericFileType == null) {
+      genericFileType = new GenericFileType();
+    }
   }
 
   private void loadTypeImages(Composite parentComposite) {
@@ -581,28 +662,14 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     searchFormData.right = new FormAttachment(100, 0);
     searchText.setLayoutData(searchFormData);
 
-    // Add listener to filter tree on text change
+    // Debounced filter: rebuild only after typing pauses (see 
SearchEverywhereDialog).
+    searchText.addListener(SWT.Modify, event -> scheduleFilterApply());
+    // Enter applies immediately
     searchText.addListener(
-        SWT.Modify,
+        SWT.DefaultSelection,
         event -> {
-          String text = searchText.getText();
-          boolean wasFiltering = !Utils.isEmpty(filterText);
-          boolean willFilter = text != null && text.length() > 2;
-
-          // Save tree state before filtering starts
-          if (!wasFiltering && willFilter) {
-            saveTreeState();
-          }
-
-          // Only filter when we have more than 2 characters, otherwise show 
all
-          filterText = willFilter ? text : "";
-          filterMatcher = new SearchMatcher(filterText, false, false, true);
-          refresh();
-
-          // Restore tree state after filtering ends
-          if (wasFiltering && !willFilter) {
-            restoreTreeState();
-          }
+          cancelScheduledFilterApply();
+          applyPendingFilter();
         });
 
     // Create a composite with toolbar and tree for the border
@@ -3303,26 +3370,266 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       if (Utils.isEmpty(searchText.getText())) {
         refresh();
       } else {
+        // Modify listener will apply the cleared filter (immediately via 
cancel + apply)
+        cancelScheduledFilterApply();
         searchText.setText("");
+        applyPendingFilter();
       }
     } else {
       filterText = "";
-      filterMatcher = new SearchMatcher("", false, false, true);
+      filterMatcher = new SearchMatcher("", false, false, false);
+      treeModel.clear();
       refresh();
     }
   }
 
   public void refresh() {
     determineRootFolderName(hopGui);
-    if (Utils.isEmpty(filterText)
-        && !Utils.isEmpty(lastSelectedFolderPath)
-        && isUnderCurrentRoot(lastSelectedFolderPath)) {
+    // Structural refresh invalidates the filter index
+    treeModel.clear();
+    if (!Utils.isEmpty(filterText)) {
+      // F5 / structural refresh while filtering: update git status, then 
re-index once
+      for (IExplorerRefreshListener listener : refreshListeners) {
+        listener.beforeRefresh();
+      }
+      applyFilterFromModel();
+      return;
+    }
+    if (!Utils.isEmpty(lastSelectedFolderPath) && 
isUnderCurrentRoot(lastSelectedFolderPath)) {
       refreshSelectedFolder();
       return;
     }
     refreshEntireTree();
   }
 
+  private void scheduleFilterApply() {
+    if (hopGui == null || hopGui.getDisplay() == null) {
+      return;
+    }
+    Display display = hopGui.getDisplay();
+    display.timerExec(-1, applyFilterRunnable);
+    display.timerExec(FILTER_DEBOUNCE_MS, applyFilterRunnable);
+  }
+
+  private void cancelScheduledFilterApply() {
+    if (hopGui == null || hopGui.getDisplay() == null) {
+      return;
+    }
+    hopGui.getDisplay().timerExec(-1, applyFilterRunnable);
+  }
+
+  /**
+   * Apply the current search box text to the explorer tree. Debounced from 
{@link SWT#Modify}; runs
+   * immediately on Enter or when clearing the filter programmatically.
+   */
+  private void applyPendingFilter() {
+    if (searchText == null || searchText.isDisposed()) {
+      return;
+    }
+
+    String text = searchText.getText();
+    boolean wasFiltering = !Utils.isEmpty(filterText);
+    boolean willFilter = text != null && text.length() > 2;
+
+    if (!wasFiltering && willFilter) {
+      saveTreeState();
+    }
+
+    filterText = willFilter ? text : "";
+    filterMatcher = new SearchMatcher(filterText, false, false, false);
+
+    if (willFilter) {
+      applyFilterFromModel();
+    } else {
+      // Leaving filter mode: full tree rebuild + restore expand state
+      treeModel.clear();
+      refreshEntireTree();
+      if (wasFiltering) {
+        restoreTreeState();
+      }
+    }
+  }
+
+  /**
+   * Rebuild the SWT tree from the in-memory model for the active filter. Does 
<em>not</em> fire
+   * {@link IExplorerRefreshListener#beforeRefresh()} (avoids re-running git 
status every
+   * keystroke).
+   */
+  private void applyFilterFromModel() {
+    try {
+      determineRootFolderName(hopGui);
+      ensureFullTreeModel();
+
+      if (tree == null || tree.isDisposed() || treeModel.isEmpty()) {
+        return;
+      }
+
+      tree.setRedraw(false);
+      try {
+        tree.removeAll();
+
+        ExplorerTreeModel.Node rootNode = treeModel.getRoot();
+        TreeItem rootItem = new TreeItem(tree, SWT.NONE);
+        rootItem.setText(Const.NVL(rootNode.getName(), ""));
+        IHopFileType fileType = getFileType(rootNode.getPath(), true);
+        setItemImage(rootItem, fileType);
+        callPaintListeners(tree, rootItem, rootNode.getPath(), 
rootNode.getName());
+        setTreeItemData(rootItem, rootNode.getPath(), rootNode.getName(), 
fileType, 0, true, true);
+
+        renderFilteredChildren(rootItem, rootNode, 0);
+        rootItem.setExpanded(true);
+        TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, rootItem, 
true);
+      } finally {
+        tree.setRedraw(true);
+      }
+    } catch (Exception e) {
+      new ErrorDialog(
+          getShell(),
+          BaseMessages.getString(PKG, 
"ExplorerPerspective.Error.TreeRefresh.Header"),
+          BaseMessages.getString(PKG, 
"ExplorerPerspective.Error.TreeRefresh.Message"),
+          e);
+    }
+    updateSelection();
+  }
+
+  /**
+   * Ensure {@link #treeModel} holds a full project walk. One VFS scan is 
reused for subsequent
+   * filter keystrokes until a structural refresh invalidates the model.
+   */
+  private void ensureFullTreeModel() throws Exception {
+    determineRootFolderName(hopGui);
+    if (treeModel.isUsableFor(rootFolder)) {
+      return;
+    }
+
+    final Exception[] error = new Exception[1];
+    BusyIndicator.showWhile(
+        hopGui.getDisplay(),
+        () -> {
+          try {
+            ExplorerTreeModel.Node rootNode =
+                new ExplorerTreeModel.Node(rootFolder, Const.NVL(rootName, 
""), true);
+            String metadataFolderName = resolveMetadataFolderName();
+            loadModelChildren(rootNode, metadataFolderName);
+            treeModel.setRoot(rootNode, true);
+          } catch (Exception e) {
+            error[0] = e;
+          }
+        });
+    if (error[0] != null) {
+      throw error[0];
+    }
+  }
+
+  private void loadModelChildren(ExplorerTreeModel.Node parent, String 
metadataFolderName)
+      throws Exception {
+    FileObject fileObject = HopVfs.getFileObject(parent.getPath());
+    FileObject[] children;
+    try {
+      children = fileObject.getChildren();
+    } catch (Exception e) {
+      return;
+    }
+    if (children == null || children.length == 0) {
+      return;
+    }
+
+    Arrays.sort(children, Comparator.comparing(Object::toString));
+
+    for (boolean folder : new boolean[] {true, false}) {
+      for (FileObject child : children) {
+        String childName = child.getName().getBaseName();
+        if (shouldSkipExplorerChild(child, childName, metadataFolderName)) {
+          continue;
+        }
+        boolean isFolder;
+        try {
+          isFolder = child.isFolder();
+        } catch (Exception e) {
+          continue;
+        }
+        if (isFolder != folder) {
+          continue;
+        }
+
+        String childPath = HopVfs.getFilename(child);
+        ExplorerTreeModel.Node childNode =
+            new ExplorerTreeModel.Node(childPath, childName, isFolder);
+        parent.addChild(childNode);
+        if (isFolder) {
+          loadModelChildren(childNode, metadataFolderName);
+        }
+      }
+    }
+  }
+
+  private String resolveMetadataFolderName() {
+    String metadataFolder = 
hopGui.getVariables().getVariable(Const.HOP_METADATA_FOLDER);
+    if (Utils.isEmpty(metadataFolder)) {
+      return null;
+    }
+    String resolvedMetadataFolder = 
hopGui.getVariables().resolve(metadataFolder);
+    if (Utils.isEmpty(resolvedMetadataFolder)) {
+      return null;
+    }
+    String normalizedMetadataFolder = resolvedMetadataFolder.replace('\\', 
'/');
+    int lastSlashIndex = normalizedMetadataFolder.lastIndexOf('/');
+    return (lastSlashIndex >= 0)
+        ? normalizedMetadataFolder.substring(lastSlashIndex + 1)
+        : normalizedMetadataFolder;
+  }
+
+  private boolean shouldSkipExplorerChild(
+      FileObject child, String childName, String metadataFolderName) {
+    boolean isMetadataFolderMatch =
+        !Utils.isEmpty(metadataFolderName) && 
metadataFolderName.equals(childName);
+    if (!showingHiddenFiles) {
+      try {
+        if (child.isHidden() || childName.startsWith(".") || 
isMetadataFolderMatch) {
+          return true;
+        }
+      } catch (Exception e) {
+        if (childName.startsWith(".") || isMetadataFolderMatch) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private void renderFilteredChildren(
+      TreeItem parentItem, ExplorerTreeModel.Node parentNode, int depth) 
throws HopException {
+    boolean filtering = ExplorerTreeModel.isFiltering(filterText);
+    for (ExplorerTreeModel.Node child : parentNode.getChildren()) {
+      if (!ExplorerTreeModel.isVisible(child, filterMatcher, filtering)) {
+        continue;
+      }
+
+      IHopFileType fileType = getFileType(child.getPath(), child.isFolder());
+      TreeItem childItem = new TreeItem(parentItem, SWT.NONE);
+      childItem.setText(child.getName());
+      setItemImage(childItem, fileType);
+
+      if (!child.isFolder() && !fileType.supportsOpening()) {
+        
childItem.setForeground(hopGui.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY));
+      } else {
+        childItem.setForeground(null);
+      }
+
+      callPaintListeners(tree, childItem, child.getPath(), child.getName());
+      setTreeItemData(
+          childItem, child.getPath(), child.getName(), fileType, depth, 
child.isFolder(), true);
+
+      if (child.isFolder()) {
+        renderFilteredChildren(childItem, child, depth + 1);
+        if (childItem.getItemCount() > 0) {
+          childItem.setExpanded(true);
+          TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, 
childItem, true);
+        }
+      }
+    }
+  }
+
   /** True when {@code path} is the explorer root or a folder inside it. */
   private boolean isUnderCurrentRoot(String path) {
     if (Utils.isEmpty(path) || Utils.isEmpty(rootFolder)) {
@@ -3525,6 +3832,10 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   private void refreshEntireTree() {
     try {
       determineRootFolderName(hopGui);
+      // Lazy UI rebuild is independent of the filter index; clear so the next 
filter re-indexes.
+      if (Utils.isEmpty(filterText)) {
+        treeModel.clear();
+      }
 
       for (IExplorerRefreshListener listener : refreshListeners) {
         listener.beforeRefresh();
@@ -3537,7 +3848,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       //
       TreeItem rootItem = new TreeItem(tree, SWT.NONE);
       rootItem.setText(Const.NVL(rootName, ""));
-      IHopFileType fileType = getFileType(rootFolder);
+      IHopFileType fileType = getFileType(rootFolder, true);
       setItemImage(rootItem, fileType);
       callPaintListeners(tree, rootItem, rootFolder, rootName);
       setTreeItemData(rootItem, rootFolder, rootName, fileType, 0, true, true);
@@ -3616,18 +3927,55 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   }
 
   public IHopFileType getFileType(String path) throws HopException {
+    return getFileType(path, null);
+  }
 
-    // get this list from the plugin registry...
-    //
-    for (IHopFileType hopFileType : fileTypes) {
-      // Only look at the extension of the file
-      //
-      if (hopFileType.isHandledBy(path, false)) {
-        return hopFileType;
+  /**
+   * Resolve the hop file type for a path. When {@code isFolder} is known 
(explorer listing), skip
+   * all extension probes for directories.
+   *
+   * @param path absolute path
+   * @param isFolder {@link Boolean#TRUE} / {@link Boolean#FALSE} when known, 
or {@code null}
+   */
+  public IHopFileType getFileType(String path, Boolean isFolder) throws 
HopException {
+    if (Boolean.TRUE.equals(isFolder)) {
+      return folderFileType != null ? folderFileType : new FolderFileType();
+    }
+
+    // Fast path: extension map (most project files)
+    String extension = HopFileTypeBase.extractExtension(path);
+    if (!Utils.isEmpty(extension)) {
+      IHopFileType byExt = fileTypeByExtension.get(extension);
+      if (byExt != null) {
+        return byExt;
+      }
+    }
+
+    // Exact base-name types (Dockerfile, .gitignore, config, ...)
+    String baseName = HopFileTypeBase.extractBaseName(path);
+    if (!Utils.isEmpty(baseName)) {
+      IHopFileType byName = fileTypeByBaseName.get(baseName);
+      if (byName != null) {
+        return byName;
+      }
+    }
+
+    // Fallback for custom handlers not covered by the maps (should be rare)
+    if (fileTypes != null) {
+      for (IHopFileType hopFileType : fileTypes) {
+        if (hopFileType instanceof FolderFileType || hopFileType instanceof 
GenericFileType) {
+          continue;
+        }
+        if (hopFileType.isHandledBy(path, false)) {
+          return hopFileType;
+        }
       }
     }
 
-    return new EmptyFileType();
+    if (Boolean.FALSE.equals(isFolder) || isFolder == null) {
+      return genericFileType != null ? genericFileType : emptyFileType;
+    }
+    return emptyFileType;
   }
 
   private void refreshFolder(TreeItem item, String path, int depth) {
@@ -3641,115 +3989,87 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
       FileObject fileObject = HopVfs.getFileObject(path);
       FileObject[] children = fileObject.getChildren();
-
-      // Sort by full path ascending
-      Arrays.sort(children, Comparator.comparing(Object::toString));
-
-      String metadataFolder = 
hopGui.getVariables().getVariable(Const.HOP_METADATA_FOLDER);
-      String metadataFolderName = null;
-      if (!Utils.isEmpty(metadataFolder)) {
-        String resolvedMetadataFolder = 
hopGui.getVariables().resolve(metadataFolder);
-        if (!Utils.isEmpty(resolvedMetadataFolder)) {
-          String normalizedMetadataFolder = 
resolvedMetadataFolder.replace('\\', '/');
-          int lastSlashIndex = normalizedMetadataFolder.lastIndexOf('/');
-          metadataFolderName =
-              (lastSlashIndex >= 0)
-                  ? normalizedMetadataFolder.substring(lastSlashIndex + 1)
-                  : normalizedMetadataFolder;
-        }
+      if (children == null || children.length == 0) {
+        return;
       }
 
-      for (boolean folder : new boolean[] {true, false}) {
-        for (FileObject child : children) {
+      String metadataFolderName = resolveMetadataFolderName();
+      String maxDepthString = 
ExplorerPerspectiveConfigSingleton.getConfig().getLazyLoadingDepth();
+      int maxDepth = 
Const.toInt(hopGui.getVariables().resolve(maxDepthString), 0);
 
-          String childName = child.getName().getBaseName();
-          boolean isMetadataFolderMatch =
-              !Utils.isEmpty(metadataFolderName) && 
metadataFolderName.equals(childName);
+      // Classify once (folder vs file), then sort each group — avoids the old 
two-pass scan that
+      // called isFolder() twice per child.
+      List<FileObject> folderChildren = new ArrayList<>(children.length / 4 + 
1);
+      List<FileObject> fileChildren = new ArrayList<>(children.length);
+      for (FileObject child : children) {
+        String childName = child.getName().getBaseName();
+        if (shouldSkipExplorerChild(child, childName, metadataFolderName)) {
+          continue;
+        }
+        boolean isFolder;
+        try {
+          isFolder = child.isFolder();
+        } catch (Exception e) {
+          continue;
+        }
+        if (isFolder) {
+          folderChildren.add(child);
+        } else {
+          fileChildren.add(child);
+        }
+      }
 
-          // Skip hidden files or folders
-          if (!showingHiddenFiles
-              && (child.isHidden() || childName.startsWith(".") || 
isMetadataFolderMatch)) {
-            continue;
-          }
+      Comparator<FileObject> byPath = Comparator.comparing(Object::toString);
+      folderChildren.sort(byPath);
+      fileChildren.sort(byPath);
 
-          if (child.isFolder() != folder) {
-            continue;
-          }
+      for (FileObject child : folderChildren) {
+        addExplorerTreeChild(item, child, depth, true, maxDepth);
+      }
+      for (FileObject child : fileChildren) {
+        addExplorerTreeChild(item, child, depth, false, maxDepth);
+      }
 
-          // Apply filter if search text is not empty
-          if (!Utils.isEmpty(filterText)
-              && !filterMatcher.matches(childName)
-              && !hasMatchingDescendant(child)) {
-            continue;
-          }
+    } catch (Exception e) {
+      TreeItem treeItem = new TreeItem(item, SWT.NONE);
+      treeItem.setText("!!Error refreshing folder!!");
+      hopGui.getLog().logError("Error refresh folder '" + path + "'", e);
+    }
+  }
 
-          String childPath = HopVfs.getFilename(child);
+  private void addExplorerTreeChild(
+      TreeItem parent, FileObject child, int depth, boolean folder, int 
maxDepth) throws Exception {
+    String childName = child.getName().getBaseName();
+    String childPath = HopVfs.getFilename(child);
+    IHopFileType fileType = getFileType(childPath, folder);
 
-          IHopFileType fileType = getFileType(childPath);
-          TreeItem childItem = new TreeItem(item, SWT.NONE);
-          childItem.setText(childName);
-          setItemImage(childItem, fileType);
+    TreeItem childItem = new TreeItem(parent, SWT.NONE);
+    childItem.setText(childName);
+    setItemImage(childItem, fileType);
 
-          // Apply gray for non-openable files before paint listeners so 
listeners (e.g. git) can
-          // use gray variants when they see this styling
-          if (!folder && !fileType.supportsOpening()) {
-            
childItem.setForeground(hopGui.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY));
-          } else {
-            childItem.setForeground(null);
-          }
+    // Apply gray for non-openable files before paint listeners so listeners 
(e.g. git) can
+    // use gray variants when they see this styling
+    if (!folder && !fileType.supportsOpening()) {
+      
childItem.setForeground(hopGui.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY));
+    } else {
+      childItem.setForeground(null);
+    }
 
-          callPaintListeners(tree, childItem, childPath, childName);
-          setTreeItemData(childItem, childPath, childName, fileType, depth, 
folder, true);
+    callPaintListeners(tree, childItem, childPath, childName);
+    setTreeItemData(childItem, childPath, childName, fileType, depth, folder, 
true);
 
-          // Recursively add children
-          //
-          if (child.isFolder()) {
-            // What is the maximum depth to lazily load?
-            //
-            String maxDepthString =
-                
ExplorerPerspectiveConfigSingleton.getConfig().getLazyLoadingDepth();
-            int maxDepth = 
Const.toInt(hopGui.getVariables().resolve(maxDepthString), 0);
-
-            // If filtering is active, we want to load and expand more to show 
matches
-            boolean isFiltering = !Utils.isEmpty(filterText);
-
-            if (depth + 1 <= maxDepth || isFiltering) {
-              // Remember folder data to expand easily
-              //
-              childItem.setData(
-                  new TreeItemFolder(
-                      childItem, childPath, childName, fileType, depth, 
folder, true));
-
-              // We actually load the content up to the desired depth
-              //
-              refreshFolder(childItem, childPath, depth + 1);
-
-              // Auto-expand if filtering and this folder has visible children
-              if (isFiltering && childItem.getItemCount() > 0) {
-                childItem.setExpanded(true);
-                TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, 
childItem, true);
-              }
-            } else {
-              // Remember folder data to expand easily
-              //
-              childItem.setData(
-                  new TreeItemFolder(
-                      childItem, childPath, childName, fileType, depth, 
folder, false));
-
-              // Create a new item to get the "expand" icon but without the 
content behind it.
-              // The folder just contains an empty item to show the expand 
icon.
-              //
-              new TreeItem(childItem, SWT.NONE);
-              childItem.setExpanded(false);
-            }
-          }
-        }
+    if (folder) {
+      if (depth + 1 <= maxDepth) {
+        childItem.setData(
+            new TreeItemFolder(childItem, childPath, childName, fileType, 
depth, true, true));
+        refreshFolder(childItem, childPath, depth + 1);
+      } else {
+        childItem.setData(
+            new TreeItemFolder(childItem, childPath, childName, fileType, 
depth, true, false));
+        // Dummy child to show the expand icon without loading content
+        new TreeItem(childItem, SWT.NONE);
+        childItem.setExpanded(false);
       }
-
-    } catch (Exception e) {
-      TreeItem treeItem = new TreeItem(item, SWT.NONE);
-      treeItem.setText("!!Error refreshing folder!!");
-      hopGui.getLog().logError("Error refresh folder '" + path + "'", e);
     }
   }
 
@@ -3856,67 +4176,6 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
   }
 
-  /**
-   * Check if a folder has any descendants (files or folders) that match the 
filter text. This
-   * allows parent folders to be shown if any of their children match the 
filter.
-   *
-   * @param folder The folder to check
-   * @return true if the folder or any of its descendants match the filter
-   */
-  private boolean hasMatchingDescendant(FileObject folder) {
-    return hasMatchingDescendant(folder, 0, 10); // Limit search depth to 10 
levels
-  }
-
-  /**
-   * Check if a folder has any descendants (files or folders) that match the 
filter text.
-   *
-   * @param folder The folder to check
-   * @param currentDepth The current recursion depth
-   * @param maxDepth The maximum depth to search
-   * @return true if the folder or any of its descendants match the filter
-   */
-  private boolean hasMatchingDescendant(FileObject folder, int currentDepth, 
int maxDepth) {
-    if (Utils.isEmpty(filterText)) {
-      return true;
-    }
-
-    // Limit recursion depth to avoid performance issues
-    if (currentDepth >= maxDepth) {
-      return false;
-    }
-
-    try {
-      if (!folder.isFolder()) {
-        return false;
-      }
-
-      FileObject[] children = folder.getChildren();
-      for (FileObject child : children) {
-        String childName = child.getName().getBaseName();
-
-        // Skip hidden files if needed
-        if (!showingHiddenFiles && (child.isHidden() || 
childName.startsWith("."))) {
-          continue;
-        }
-
-        // Check if this child matches
-        if (filterMatcher.matches(childName)) {
-          return true;
-        }
-
-        // Recursively check descendants
-        if (child.isFolder() && hasMatchingDescendant(child, currentDepth + 1, 
maxDepth)) {
-          return true;
-        }
-      }
-    } catch (Exception e) {
-      // If there's an error reading the folder, assume no match
-      return false;
-    }
-
-    return false;
-  }
-
   /** Update de tab name, tooltip and set the tab bold if the file has changed 
and vice versa. */
   public void updateTabItem(IHopFileTypeHandler fileTypeHandler) {
     TabItemHandler tabItemHandler = this.getTabItemHandler(fileTypeHandler);
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerTreeModel.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerTreeModel.java
new file mode 100644
index 0000000000..ecb2e43780
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerTreeModel.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.explorer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import lombok.AccessLevel;
+import lombok.Getter;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * In-memory project file tree used by the explorer filter. Built once from 
VFS (or a full walk) so
+ * subsequent filter keystrokes match and render without re-reading the 
filesystem.
+ */
+public class ExplorerTreeModel {
+
+  @Getter private Node root;
+  @Getter private boolean fullyLoaded;
+
+  public void clear() {
+    root = null;
+    fullyLoaded = false;
+  }
+
+  public boolean isEmpty() {
+    return root == null;
+  }
+
+  /**
+   * Whether this model can serve filter queries for the given project root 
without another VFS
+   * walk.
+   */
+  public boolean isUsableFor(String rootPath) {
+    return fullyLoaded
+        && root != null
+        && rootPath != null
+        && Objects.equals(root.getPath(), rootPath);
+  }
+
+  public void setRoot(Node root, boolean fullyLoaded) {
+    this.root = root;
+    this.fullyLoaded = fullyLoaded && root != null;
+  }
+
+  /**
+   * A node is visible under a filter when its name matches or any descendant 
is visible. With no
+   * filter every node is visible.
+   */
+  public static boolean isVisible(Node node, SearchMatcher matcher, boolean 
filtering) {
+    if (node == null) {
+      return false;
+    }
+    if (!filtering || matcher == null) {
+      return true;
+    }
+    if (matcher.matches(node.getName())) {
+      return true;
+    }
+    if (!node.isFolder()) {
+      return false;
+    }
+    for (Node child : node.getChildren()) {
+      if (isVisible(child, matcher, true)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** True when the filter string is active (non-empty after the UI threshold 
is applied). */
+  public static boolean isFiltering(String filterText) {
+    return !Utils.isEmpty(filterText);
+  }
+
+  /** One file or folder in the project tree. */
+  @Getter
+  public static final class Node {
+    private final String path;
+    private final String name;
+    private final boolean folder;
+
+    @Getter(AccessLevel.NONE)
+    private final List<Node> children = new ArrayList<>();
+
+    public Node(String path, String name, boolean folder) {
+      this.path = path;
+      this.name = name;
+      this.folder = folder;
+    }
+
+    public void addChild(Node child) {
+      if (child != null) {
+        children.add(child);
+      }
+    }
+
+    public List<Node> getChildren() {
+      return children;
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/ArchiveFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/ArchiveFileType.java
index 07a184728f..65a1a77a1f 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/ArchiveFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/ArchiveFileType.java
@@ -20,16 +20,15 @@ package 
org.apache.hop.ui.hopgui.perspective.explorer.file.types;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Properties;
-import org.apache.commons.vfs2.FileName;
-import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.file.IHasFilename;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
 import org.apache.hop.ui.hopgui.file.HopFileTypePlugin;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
@@ -89,26 +88,16 @@ public class ArchiveFileType implements IHopFileType {
 
   @Override
   public boolean isHandledBy(String filename, boolean checkContent) throws 
HopException {
-    try {
-      FileObject fileObject = HopVfs.getFileObject(filename);
-      FileName fileName = fileObject.getName();
-      String fileExtension = fileName.getExtension().toLowerCase();
-
-      // No extension
-      if (Utils.isEmpty(fileExtension)) return false;
-
-      // Verify the extension
-      //
-      for (String typeExtension : EXTENSIONS) {
-        if (typeExtension.toLowerCase().endsWith(fileExtension)) {
-          return true;
-        }
-      }
+    String fileExtension = HopFileTypeBase.extractExtension(filename);
+    if (Utils.isEmpty(fileExtension)) {
       return false;
-    } catch (Exception e) {
-      throw new HopException(
-          "Unable to verify file handling of file '" + filename + "' by 
extension", e);
     }
+    for (String typeExtension : EXTENSIONS) {
+      if (typeExtension.toLowerCase(Locale.ROOT).endsWith(fileExtension)) {
+        return true;
+      }
+    }
+    return false;
   }
 
   @Override
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/FolderFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/FolderFileType.java
index 549b66c81d..2d78541cd5 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/FolderFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/FolderFileType.java
@@ -24,7 +24,6 @@ import java.util.Properties;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.file.IHasFilename;
 import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.file.HopFileTypePlugin;
@@ -81,20 +80,14 @@ public class FolderFileType implements IHopFileType {
   }
 
   /**
-   * See if this is a folder
-   *
-   * @param filename The filename
-   * @param checkContent True if we want to look inside the file content
-   * @return
-   * @throws HopException
+   * Folders cannot be detected from a path string alone. Callers that already 
know the entry is a
+   * folder (e.g. the explorer after {@code FileObject.isFolder()}) must 
select this type explicitly
+   * rather than probing here — a VFS {@code isFolder()} on every candidate 
path dominated large
+   * project tree builds.
    */
   @Override
   public boolean isHandledBy(String filename, boolean checkContent) throws 
HopException {
-    try {
-      return HopVfs.getFileObject(filename).isFolder();
-    } catch (Exception e) {
-      throw new HopException("Error seeing if file '" + filename + "' is a 
folder", e);
-    }
+    return false;
   }
 
   @Override
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/GenericFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/GenericFileType.java
index 2c7c005129..9935f42aac 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/GenericFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/GenericFileType.java
@@ -23,8 +23,8 @@ import java.util.List;
 import java.util.Properties;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.file.IHasFilename;
+import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
@@ -77,20 +77,12 @@ public class GenericFileType implements IHopFileType {
   }
 
   /**
-   * See if this is a generic file
-   *
-   * @param filename The filename
-   * @param checkContent True if we want to look inside the file content
-   * @return
-   * @throws HopException
+   * Catch-all for files that no specialized type claimed. Must not touch VFS: 
this type is last in
+   * the explorer list and used to be invoked with a full {@code isFile()} 
probe per unmatched path.
    */
   @Override
   public boolean isHandledBy(String filename, boolean checkContent) throws 
HopException {
-    try {
-      return HopVfs.getFileObject(filename).isFile();
-    } catch (Exception e) {
-      throw new HopException("Error seeing if file '" + filename + "' is a 
generic file", e);
-    }
+    return !Utils.isEmpty(filename);
   }
 
   @Override
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/noext/NoExtensionExplorerFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/noext/NoExtensionExplorerFileType.java
index a26372a573..741cebb9a8 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/noext/NoExtensionExplorerFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/noext/NoExtensionExplorerFileType.java
@@ -17,11 +17,10 @@
 
 package org.apache.hop.ui.hopgui.perspective.explorer.file.types.noext;
 
-import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
 import org.apache.hop.ui.hopgui.file.HopFileTypePlugin;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
@@ -75,9 +74,7 @@ public class NoExtensionExplorerFileType
 
   @Override
   public boolean isHandledBy(String filename, boolean checkContent) throws 
HopException {
-    FileObject fileObject = HopVfs.getFileObject(filename);
-    String baseName = fileObject.getName().getBaseName();
-
+    String baseName = HopFileTypeBase.extractBaseName(filename);
     for (String extension : getFilterExtensions()) {
       if (extension.equals(baseName)) {
         return true;
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/file/HopFileTypeBaseTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/file/HopFileTypeBaseTest.java
new file mode 100644
index 0000000000..ae91659618
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/file/HopFileTypeBaseTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.file;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Properties;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.file.IHasFilename;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.junit.jupiter.api.Test;
+
+class HopFileTypeBaseTest {
+
+  @Test
+  void extractExtensionFromUnixPath() {
+    assertEquals("hpl", 
HopFileTypeBase.extractExtension("/project/pipelines/demo.hpl"));
+    assertEquals("hwf", HopFileTypeBase.extractExtension("/a/b/c.HWF"));
+  }
+
+  @Test
+  void extractExtensionFromWindowsPath() {
+    assertEquals("json", 
HopFileTypeBase.extractExtension("C:\\data\\file.JSON"));
+  }
+
+  @Test
+  void extractExtensionHiddenFileHasNone() {
+    assertEquals("", 
HopFileTypeBase.extractExtension("/home/user/.gitignore"));
+    assertEquals("", HopFileTypeBase.extractExtension(".profile"));
+  }
+
+  @Test
+  void extractExtensionMissing() {
+    assertEquals("", HopFileTypeBase.extractExtension("README"));
+    assertEquals("", HopFileTypeBase.extractExtension(""));
+    assertEquals("", HopFileTypeBase.extractExtension(null));
+  }
+
+  @Test
+  void extractBaseName() {
+    assertEquals("demo.hpl", 
HopFileTypeBase.extractBaseName("/project/demo.hpl"));
+    assertEquals("Dockerfile", 
HopFileTypeBase.extractBaseName("C:\\repo\\Dockerfile"));
+  }
+
+  @Test
+  void isHandledByMatchesFilterWithoutVfs() throws HopException {
+    TestFileType type = new TestFileType(new String[] {"*.hpl", "*.xml"});
+    assertTrue(type.isHandledBy("/tmp/pipe.hpl", false));
+    assertTrue(type.isHandledBy("C:\\x\\a.XML", false));
+    assertFalse(type.isHandledBy("/tmp/pipe.hwf", false));
+    assertFalse(type.isHandledBy("/tmp/noext", false));
+  }
+
+  @Test
+  void isHandledBySupportsCompoundFilters() throws HopException {
+    TestFileType type = new TestFileType(new String[] {"*.xls;*.xlsx"});
+    assertTrue(type.isHandledBy("/data/sheet.xlsx", false));
+    assertTrue(type.isHandledBy("/data/legacy.xls", false));
+    assertFalse(type.isHandledBy("/data/sheet.ods", false));
+  }
+
+  /** Minimal concrete type for matching tests. */
+  private static final class TestFileType extends HopFileTypeBase {
+    private final String[] filters;
+
+    private TestFileType(String[] filters) {
+      this.filters = filters;
+    }
+
+    @Override
+    public String getName() {
+      return "test";
+    }
+
+    @Override
+    public String getDefaultFileExtension() {
+      return ".hpl";
+    }
+
+    @Override
+    public String[] getFilterExtensions() {
+      return filters;
+    }
+
+    @Override
+    public String[] getFilterNames() {
+      return new String[] {"test"};
+    }
+
+    @Override
+    public Properties getCapabilities() {
+      return new Properties();
+    }
+
+    @Override
+    public IHopFileTypeHandler openFile(HopGui hopGui, String filename, 
IVariables variables) {
+      return null;
+    }
+
+    @Override
+    public IHopFileTypeHandler newFile(HopGui hopGui, IVariables 
parentVariableSpace) {
+      return null;
+    }
+
+    @Override
+    public boolean supportsFile(IHasFilename metaObject) {
+      return false;
+    }
+
+    @Override
+    public java.util.List<IGuiContextHandler> getContextHandlers() {
+      return java.util.Collections.emptyList();
+    }
+
+    @Override
+    public String getFileTypeImage() {
+      return null;
+    }
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerTreeModelTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerTreeModelTest.java
new file mode 100644
index 0000000000..636ef7c0a4
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerTreeModelTest.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.hopgui.perspective.explorer;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerTreeModel.Node;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ExplorerTreeModelTest {
+
+  private ExplorerTreeModel model;
+  private Node root;
+  private Node folderA;
+  private Node deepFile;
+  private Node otherFile;
+
+  @BeforeEach
+  void setUp() {
+    model = new ExplorerTreeModel();
+    root = new Node("/project", "project", true);
+    folderA = new Node("/project/a", "a", true);
+    Node folderB = new Node("/project/a/b", "b", true);
+    deepFile = new Node("/project/a/b/pipeline.hpl", "pipeline.hpl", false);
+    otherFile = new Node("/project/readme.md", "readme.md", false);
+
+    folderB.addChild(deepFile);
+    folderA.addChild(folderB);
+    root.addChild(folderA);
+    root.addChild(otherFile);
+    model.setRoot(root, true);
+  }
+
+  @Test
+  void isUsableForRootPath() {
+    assertTrue(model.isUsableFor("/project"));
+    assertFalse(model.isUsableFor("/other"));
+    model.clear();
+    assertFalse(model.isUsableFor("/project"));
+  }
+
+  @Test
+  void withoutFilterEverythingIsVisible() {
+    SearchMatcher matcher = new SearchMatcher("", false, false, false);
+    assertTrue(ExplorerTreeModel.isVisible(root, matcher, false));
+    assertTrue(ExplorerTreeModel.isVisible(deepFile, matcher, false));
+  }
+
+  @Test
+  void matchingLeafMakesAncestorsVisible() {
+    SearchMatcher matcher = new SearchMatcher("pipeline", false, false, false);
+    assertTrue(ExplorerTreeModel.isVisible(deepFile, matcher, true));
+    assertTrue(ExplorerTreeModel.isVisible(folderA, matcher, true));
+    assertTrue(ExplorerTreeModel.isVisible(root, matcher, true));
+    assertFalse(ExplorerTreeModel.isVisible(otherFile, matcher, true));
+  }
+
+  @Test
+  void nonMatchingNameHiddenWhenNoDescendantMatches() {
+    SearchMatcher matcher = new SearchMatcher("zzz-no-match", false, false, 
false);
+    assertFalse(ExplorerTreeModel.isVisible(otherFile, matcher, true));
+    assertFalse(ExplorerTreeModel.isVisible(folderA, matcher, true));
+    // Root name "project" does not match and no descendant matches → 
invisible as a child check
+    assertFalse(ExplorerTreeModel.isVisible(deepFile, matcher, true));
+  }
+
+  @Test
+  void folderNameMatchShowsFolderEvenWithoutMatchingChildren() {
+    Node emptyFolder = new Node("/project/special-folder", "special-folder", 
true);
+    root.addChild(emptyFolder);
+    SearchMatcher matcher = new SearchMatcher("special-folder", false, false, 
false);
+    assertTrue(ExplorerTreeModel.isVisible(emptyFolder, matcher, true));
+    assertFalse(ExplorerTreeModel.isVisible(otherFile, matcher, true));
+    assertFalse(ExplorerTreeModel.isVisible(deepFile, matcher, true));
+  }
+
+  @Test
+  void isFilteringRequiresNonEmptyText() {
+    assertFalse(ExplorerTreeModel.isFiltering(null));
+    assertFalse(ExplorerTreeModel.isFiltering(""));
+    assertTrue(ExplorerTreeModel.isFiltering("abc"));
+  }
+}

Reply via email to