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