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 c3b8a4512d issue #7292 : Search all known file types in project search 
(#7491)
c3b8a4512d is described below

commit c3b8a4512dfabe1d42b81ba586c01ecea05b13aa
Author: Matt Casters <[email protected]>
AuthorDate: Mon Jul 13 15:59:21 2026 +0200

    issue #7292 : Search all known file types in project search (#7491)
    
    Allow IHopFileType to opt into search via CAPABILITY_SEARCH and
    createSearchable(), so ProjectSearchablesIterator discovers text and
    other registered file types instead of only pipelines and workflows.
---
 .../search/ProjectSearchablesIterator.java         |  82 +++++++++++------
 .../hop/ui/hopgui/file/HopFileTypeRegistry.java    |  43 +++++++++
 .../apache/hop/ui/hopgui/file/IHopFileType.java    |  29 ++++++
 .../hop/ui/hopgui/file/IHopFileTypeHandler.java    |  23 +++++
 .../hopgui/file/pipeline/HopPipelineFileType.java  |  15 ++++
 .../hopgui/file/workflow/HopWorkflowFileType.java  |  15 ++++
 .../perspective/explorer/ExplorerPerspective.java  |  27 ++++--
 .../file/types/text/BaseTextExplorerFileType.java  |  41 +++++++++
 .../text/BaseTextExplorerFileTypeHandler.java      |  25 ++++++
 .../hop/ui/hopgui/search/HopGuiSearchHelper.java   |  11 ++-
 .../ui/hopgui/search/HopGuiTextFileSearchable.java | 100 +++++++++++++++++++++
 .../hop/ui/hopgui/search/TextFileContent.java      |  41 +++++++++
 .../search/TextFileContentSearchableAnalyser.java  |  61 +++++++++++++
 .../ui/hopgui/search/HopGuiSearchHelperTest.java   |  31 +++++++
 .../TextFileContentSearchableAnalyserTest.java     |  86 ++++++++++++++++++
 15 files changed, 593 insertions(+), 37 deletions(-)

diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchablesIterator.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchablesIterator.java
index 475a952668..938f98cdee 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchablesIterator.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchablesIterator.java
@@ -19,8 +19,11 @@ package org.apache.hop.projects.search;
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.config.DescribedVariablesConfigFile;
 import org.apache.hop.core.config.HopConfig;
@@ -33,16 +36,14 @@ import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataSerializer;
-import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.projects.config.ProjectsConfig;
 import org.apache.hop.projects.config.ProjectsConfigSingleton;
 import org.apache.hop.projects.environment.LifecycleEnvironment;
 import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.ui.hopgui.file.HopFileTypeRegistry;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.search.HopGuiDescribedVariableSearchable;
 import org.apache.hop.ui.hopgui.search.HopGuiMetadataSearchable;
-import org.apache.hop.ui.hopgui.search.HopGuiPipelineSearchable;
-import org.apache.hop.ui.hopgui.search.HopGuiWorkflowSearchable;
-import org.apache.hop.workflow.WorkflowMeta;
 
 // TODO: implement lazy loading of the searchables.
 //
@@ -68,32 +69,61 @@ public class ProjectSearchablesIterator implements 
Iterator<ISearchable> {
         configurationFiles.addAll(environments.get(0).getConfigurationFiles());
       }
 
-      // Find all the pipelines and workflows in the project homefolder...
+      // Discover files via registered hop file types that opt into search.
       //
-      FileObject homeFolderFile = 
HopVfs.getFileObject(projectConfig.getProjectHome());
-      Collection<FileObject> pipelineFiles = HopVfs.findFiles(homeFolderFile, 
"hpl", true);
-      for (FileObject pipelineFile : pipelineFiles) {
-        String pipelineFilePath = pipelineFile.getName().getURI();
-        try {
-          PipelineMeta pipelineMeta =
-              new PipelineMeta(pipelineFilePath, metadataProvider, variables);
-          searchables.add(new HopGuiPipelineSearchable("Project pipeline 
file", pipelineMeta));
-        } catch (Exception e) {
-          // There was an error loading the XML file...
-          LogChannel.GENERAL.logError("Error loading pipeline metadata: " + 
pipelineFilePath, e);
+      HopFileTypeRegistry fileTypeRegistry = HopFileTypeRegistry.getInstance();
+      fileTypeRegistry.ensureLoaded();
+
+      Set<String> searchableExtensions = new HashSet<>();
+      boolean hasSearchableTypes = false;
+      for (IHopFileType fileType : fileTypeRegistry.getFileTypes()) {
+        if (!fileType.hasCapability(IHopFileType.CAPABILITY_SEARCH)) {
+          continue;
+        }
+        hasSearchableTypes = true;
+        for (String filterExtension : fileType.getFilterExtensions()) {
+          if (filterExtension == null || filterExtension.isEmpty()) {
+            continue;
+          }
+          // filter extensions look like "*.hpl" or "*.sql;*.SQL"
+          for (String part : filterExtension.split(";")) {
+            String ext = part.trim();
+            if (ext.startsWith("*.")) {
+              ext = ext.substring(2);
+            } else if (ext.startsWith(".")) {
+              ext = ext.substring(1);
+            }
+            if (!ext.isEmpty() && !"*".equals(ext)) {
+              searchableExtensions.add(ext.toLowerCase(Locale.ROOT));
+            }
+          }
         }
       }
 
-      Collection<FileObject> workflowFiles = HopVfs.findFiles(homeFolderFile, 
"hwf", true);
-      for (FileObject workflowFile : workflowFiles) {
-        String workflowFilePath = workflowFile.getName().getURI();
-        try {
-          WorkflowMeta workflowMeta =
-              new WorkflowMeta(variables, workflowFilePath, metadataProvider);
-          searchables.add(new HopGuiWorkflowSearchable("Project workflow 
file", workflowMeta));
-        } catch (Exception e) {
-          // There was an error loading the XML file...
-          LogChannel.GENERAL.logError("Error loading workflow metadata: " + 
workflowFilePath, e);
+      FileObject homeFolderFile = 
HopVfs.getFileObject(projectConfig.getProjectHome());
+      if (hasSearchableTypes) {
+        Collection<FileObject> projectFiles = HopVfs.findFiles(homeFolderFile, 
null, true);
+        for (FileObject projectFile : projectFiles) {
+          String extension = projectFile.getName().getExtension();
+          if (extension == null
+              || extension.isEmpty()
+              || 
!searchableExtensions.contains(extension.toLowerCase(Locale.ROOT))) {
+            continue;
+          }
+          String filePath = projectFile.getName().getURI();
+          try {
+            IHopFileType fileType = fileTypeRegistry.findHopFileType(filePath);
+            if (fileType == null || 
!fileType.hasCapability(IHopFileType.CAPABILITY_SEARCH)) {
+              continue;
+            }
+            ISearchable searchable =
+                fileType.createSearchable(filePath, "Project file", variables, 
metadataProvider);
+            if (searchable != null) {
+              searchables.add(searchable);
+            }
+          } catch (Exception e) {
+            LogChannel.GENERAL.logError("Error loading searchable file: " + 
filePath, e);
+          }
         }
       }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeRegistry.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeRegistry.java
index 96c2125b3b..7e28d9df98 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeRegistry.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/HopFileTypeRegistry.java
@@ -22,6 +22,10 @@ import java.util.Arrays;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopPluginException;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.i18n.BaseMessages;
 
 /** This class contains all the available Hop File types */
@@ -32,9 +36,11 @@ public class HopFileTypeRegistry {
   private static HopFileTypeRegistry hopFileTypeRegistry;
 
   private List<IHopFileType> hopFileTypes;
+  private boolean pluginsLoaded;
 
   private HopFileTypeRegistry() {
     hopFileTypes = new ArrayList<>();
+    pluginsLoaded = false;
   }
 
   public static final HopFileTypeRegistry getInstance() {
@@ -54,6 +60,43 @@ public class HopFileTypeRegistry {
     }
   }
 
+  /**
+   * Ensure {@link HopFileTypePluginType} plugins are registered and loaded 
into this registry. Safe
+   * to call multiple times (e.g. from Hop GUI and from project search / 
hop-search). When the GUI
+   * already populated the registry, this is a no-op.
+   */
+  public synchronized void ensureLoaded() {
+    if (pluginsLoaded || !hopFileTypes.isEmpty()) {
+      pluginsLoaded = true;
+      return;
+    }
+    try {
+      PluginRegistry registry = PluginRegistry.getInstance();
+      List<IPlugin> plugins = registry.getPlugins(HopFileTypePluginType.class);
+      // hop-search may not have called HopGuiEnvironment; register and scan 
if needed.
+      if (plugins.isEmpty()) {
+        PluginRegistry.addPluginType(HopFileTypePluginType.getInstance());
+        HopFileTypePluginType.getInstance().searchPlugins();
+        plugins = registry.getPlugins(HopFileTypePluginType.class);
+      }
+      for (IPlugin plugin : plugins) {
+        try {
+          IHopFileType hopFileType = registry.loadClass(plugin, 
IHopFileType.class);
+          registerHopFile(hopFileType);
+        } catch (HopPluginException e) {
+          LogChannel.GENERAL.logError(
+              "Unable to load hop file type plugin with ID '"
+                  + (plugin.getIds().length > 0 ? plugin.getIds()[0] : "?")
+                  + "'",
+              e);
+        }
+      }
+      pluginsLoaded = true;
+    } catch (Exception e) {
+      LogChannel.GENERAL.logError("Error loading hop file type plugins for 
search", e);
+    }
+  }
+
   /**
    * This method first tries to find a HopFile by looking at the extension. If 
none can be found the
    * content is looked at by each IHopFileType
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileType.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileType.java
index c1c4c5002d..151ec1c60b 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileType.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileType.java
@@ -21,7 +21,9 @@ 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.search.ISearchable;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 
@@ -56,6 +58,12 @@ public interface IHopFileType {
 
   String CAPABILITY_HANDLE_METADATA = "HandleMetadata";
 
+  /**
+   * Opt-in capability: include files of this type when enumerating project 
(and open-tab)
+   * searchables. Types without this capability are not discovered by 
project-wide search.
+   */
+  String CAPABILITY_SEARCH = "Search";
+
   /**
    * @return The name of this file type
    */
@@ -146,4 +154,25 @@ public interface IHopFileType {
   default boolean supportsOpening() {
     return true;
   }
+
+  /**
+   * Build a searchable representation of the given file without opening it in 
the UI. Called by
+   * project search when this type {@linkplain #hasCapability(String) has} 
{@link
+   * #CAPABILITY_SEARCH}. The default returns {@code null} (not searchable).
+   *
+   * @param filename the path of the file on disk
+   * @param locationDescription a short label for where the file was found 
(e.g. "Project file")
+   * @param variables the variables to use when loading the file
+   * @param metadataProvider the metadata provider (pipelines/workflows may 
need it)
+   * @return an {@link ISearchable}, or {@code null} if this type cannot 
search the file
+   * @throws HopException if loading the file fails
+   */
+  default ISearchable createSearchable(
+      String filename,
+      String locationDescription,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider)
+      throws HopException {
+    return null;
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java
index eb4acb1a80..2e39abd317 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java
@@ -19,7 +19,9 @@ package org.apache.hop.ui.hopgui.file;
 
 import java.util.Map;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.search.ISearchable;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.hopgui.context.IActionContextHandlersProvider;
 
 /** This describes the main file operations for a supported Hop file */
@@ -174,4 +176,25 @@ public interface IHopFileTypeHandler extends 
IActionContextHandlersProvider {
   default boolean hasCapability(String capability) {
     return getFileType() != null && getFileType().hasCapability(capability);
   }
+
+  /**
+   * Build a searchable for the file this handler represents (typically an 
open tab). Defaults to
+   * delegating to {@link IHopFileType#createSearchable} when the file type 
has {@link
+   * IHopFileType#CAPABILITY_SEARCH}. Handlers with in-memory content may 
override to search the
+   * editor buffer instead of reloading from disk.
+   *
+   * @param locationDescription a short label for where the file was found 
(e.g. "Open file")
+   * @param metadataProvider the metadata provider
+   * @return an {@link ISearchable}, or {@code null} if this handler is not 
searchable
+   * @throws HopException if building the searchable fails
+   */
+  default ISearchable createSearchable(
+      String locationDescription, IHopMetadataProvider metadataProvider) 
throws HopException {
+    IHopFileType type = getFileType();
+    if (type == null || !type.hasCapability(IHopFileType.CAPABILITY_SEARCH)) {
+      return null;
+    }
+    return type.createSearchable(
+        getFilename(), locationDescription, getVariables(), metadataProvider);
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
index c05e7cb470..30acd970c2 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopPipelineFileType.java
@@ -26,11 +26,13 @@ import org.apache.hop.core.extension.HopExtensionPoint;
 import org.apache.hop.core.file.IHasFilename;
 import org.apache.hop.core.gui.plugin.action.GuiAction;
 import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.core.search.ISearchable;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.history.AuditManager;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.gui.HopNamespace;
@@ -41,6 +43,7 @@ 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;
+import org.apache.hop.ui.hopgui.search.HopGuiPipelineSearchable;
 import org.w3c.dom.Document;
 import org.w3c.dom.Node;
 
@@ -105,6 +108,7 @@ public class HopPipelineFileType<T extends PipelineMeta> 
extends HopFileTypeBase
     capabilities.setProperty(IHopFileType.CAPABILITY_DISTRIBUTE_VERTICAL, 
"true");
 
     capabilities.setProperty(IHopFileType.CAPABILITY_FILE_HISTORY, "true");
+    capabilities.setProperty(IHopFileType.CAPABILITY_SEARCH, "true");
 
     return capabilities;
   }
@@ -249,4 +253,15 @@ public class HopPipelineFileType<T extends PipelineMeta> 
extends HopFileTypeBase
   public String getFileTypeImage() {
     return "ui/images/pipeline.svg";
   }
+
+  @Override
+  public ISearchable createSearchable(
+      String filename,
+      String locationDescription,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider)
+      throws HopException {
+    PipelineMeta pipelineMeta = new PipelineMeta(filename, metadataProvider, 
variables);
+    return new HopGuiPipelineSearchable(locationDescription, pipelineMeta);
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
index b8cae0adcc..50b4a6fad8 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopWorkflowFileType.java
@@ -26,11 +26,13 @@ import org.apache.hop.core.extension.HopExtensionPoint;
 import org.apache.hop.core.file.IHasFilename;
 import org.apache.hop.core.gui.plugin.action.GuiAction;
 import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.core.search.ISearchable;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.history.AuditManager;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.hopgui.HopGui;
@@ -40,6 +42,7 @@ 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;
+import org.apache.hop.ui.hopgui.search.HopGuiWorkflowSearchable;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionMeta;
 import org.apache.hop.workflow.actions.start.ActionStart;
@@ -110,6 +113,7 @@ public class HopWorkflowFileType<T extends WorkflowMeta> 
extends HopFileTypeBase
     capabilities.setProperty(IHopFileType.CAPABILITY_DISTRIBUTE_VERTICAL, 
"true");
 
     capabilities.setProperty(IHopFileType.CAPABILITY_FILE_HISTORY, "true");
+    capabilities.setProperty(IHopFileType.CAPABILITY_SEARCH, "true");
 
     return capabilities;
   }
@@ -259,4 +263,15 @@ public class HopWorkflowFileType<T extends WorkflowMeta> 
extends HopFileTypeBase
   public String getFileTypeImage() {
     return "ui/images/workflow.svg";
   }
+
+  @Override
+  public ISearchable createSearchable(
+      String filename,
+      String locationDescription,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider)
+      throws HopException {
+    WorkflowMeta workflowMeta = new WorkflowMeta(variables, filename, 
metadataProvider);
+    return new HopGuiWorkflowSearchable(locationDescription, workflowMeta);
+  }
 }
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 83f5e80c97..a6adbaed89 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
@@ -51,6 +51,7 @@ import 
org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
 import org.apache.hop.core.listeners.IContentChangedListener;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.search.ISearchResult;
@@ -3901,19 +3902,31 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
   @Override
   public List<ISearchable> getSearchables() {
-    // Surface the pipelines and workflows that are currently open in tabs so 
they show up - and can
-    // be prioritized - in the global search.
+    // Surface open tabs so they show up - and can be prioritized - in the 
global search.
+    // Pipelines/workflows use the in-memory subject (includes unsaved edits). 
Other file types
+    // that opt in via CAPABILITY_SEARCH are loaded through 
IHopFileTypeHandler.createSearchable.
     List<ISearchable> searchables = new ArrayList<>();
     for (TabItemHandler item : items) {
       IHopFileTypeHandler typeHandler = item.getTypeHandler();
       if (typeHandler == null) {
         continue;
       }
-      Object subject = typeHandler.getSubject();
-      if (subject instanceof PipelineMeta pipelineMeta) {
-        searchables.add(new HopGuiPipelineSearchable("Open file", 
pipelineMeta));
-      } else if (subject instanceof WorkflowMeta workflowMeta) {
-        searchables.add(new HopGuiWorkflowSearchable("Open file", 
workflowMeta));
+      try {
+        Object subject = typeHandler.getSubject();
+        if (subject instanceof PipelineMeta pipelineMeta) {
+          searchables.add(new HopGuiPipelineSearchable("Open file", 
pipelineMeta));
+        } else if (subject instanceof WorkflowMeta workflowMeta) {
+          searchables.add(new HopGuiWorkflowSearchable("Open file", 
workflowMeta));
+        } else {
+          ISearchable searchable =
+              typeHandler.createSearchable("Open file", 
hopGui.getMetadataProvider());
+          if (searchable != null) {
+            searchables.add(searchable);
+          }
+        }
+      } catch (Exception e) {
+        LogChannel.UI.logError(
+            "Error creating searchable for open file: " + 
typeHandler.getFilename(), e);
       }
     }
     return searchables;
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
index 94edfff9c1..02faf65e5a 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
@@ -17,14 +17,25 @@
 
 package org.apache.hop.ui.hopgui.perspective.explorer.file.types.text;
 
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
 import java.util.Properties;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.search.ISearchable;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import 
org.apache.hop.ui.hopgui.perspective.explorer.file.types.base.BaseExplorerFileType;
+import org.apache.hop.ui.hopgui.search.HopGuiTextFileSearchable;
+import org.apache.hop.ui.hopgui.search.TextFileContent;
 
 public abstract class BaseTextExplorerFileType<T extends 
BaseTextExplorerFileTypeHandler>
     extends BaseExplorerFileType<T> {
@@ -38,6 +49,9 @@ public abstract class BaseTextExplorerFileType<T extends 
BaseTextExplorerFileTyp
       String[] filterNames,
       Properties capabilities) {
     super(name, defaultFileExtension, filterExtensions, filterNames, 
capabilities);
+    if (capabilities != null) {
+      capabilities.setProperty(IHopFileType.CAPABILITY_SEARCH, "true");
+    }
   }
 
   public abstract T createFileTypeHandler(
@@ -46,4 +60,31 @@ public abstract class BaseTextExplorerFileType<T extends 
BaseTextExplorerFileTyp
   @Override
   public abstract IHopFileTypeHandler newFile(HopGui hopGui, IVariables 
parentVariableSpace)
       throws HopException;
+
+  @Override
+  public ISearchable createSearchable(
+      String filename,
+      String locationDescription,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider)
+      throws HopException {
+    try {
+      FileObject fileObject = HopVfs.getFileObject(filename, variables);
+      if (!fileObject.exists()) {
+        throw new HopException("File '" + filename + "' doesn't exist");
+      }
+      String text;
+      try (InputStream inputStream = HopVfs.getInputStream(fileObject)) {
+        StringWriter writer = new StringWriter();
+        IOUtils.copy(inputStream, writer, StandardCharsets.UTF_8);
+        text = writer.toString();
+      }
+      TextFileContent content = new TextFileContent(filename, text);
+      return new HopGuiTextFileSearchable(locationDescription, getName(), 
content);
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException("Error loading text file for search: " + 
filename, e);
+    }
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileTypeHandler.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileTypeHandler.java
index 36859628fb..a6f72ace63 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileTypeHandler.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileTypeHandler.java
@@ -23,14 +23,19 @@ import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.search.ISearchable;
 import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
 import org.apache.hop.ui.hopgui.ContentEditorFacade;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import 
org.apache.hop.ui.hopgui.perspective.explorer.file.types.base.BaseExplorerFileTypeHandler;
+import org.apache.hop.ui.hopgui.search.HopGuiTextFileSearchable;
+import org.apache.hop.ui.hopgui.search.TextFileContent;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.widgets.Composite;
 
@@ -161,4 +166,24 @@ public class BaseTextExplorerFileTypeHandler extends 
BaseExplorerFileTypeHandler
   public void copySelectedToClipboard() {
     editorWidget.copy();
   }
+
+  @Override
+  public ISearchable createSearchable(
+      String locationDescription, IHopMetadataProvider metadataProvider) 
throws HopException {
+    IHopFileType type = getFileType();
+    if (type == null || !type.hasCapability(IHopFileType.CAPABILITY_SEARCH)) {
+      return null;
+    }
+    // Prefer the open editor buffer so unsaved edits are searchable.
+    String text;
+    if (editorWidget != null
+        && editorWidget.getControl() != null
+        && !editorWidget.getControl().isDisposed()) {
+      text = Const.NVL(editorWidget.getText(), "");
+    } else {
+      text = Const.NVL(readTextFileContent(StandardCharsets.UTF_8), "");
+    }
+    TextFileContent content = new TextFileContent(getFilename(), text);
+    return new HopGuiTextFileSearchable(locationDescription, type.getName(), 
content);
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
index fce4d42a30..d2d46baf52 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
@@ -268,16 +268,19 @@ public final class HopGuiSearchHelper {
   }
 
   /**
-   * Whether a searchable is an object that is currently <em>open</em> in a 
Hop GUI tab. Only files
-   * (pipelines and workflows) can be open; metadata items and variables are 
always project-wide.
+   * Whether a searchable is an object that is currently <em>open</em> in a 
Hop GUI tab. Openable
+   * file objects (pipelines, workflows, text files) can be open; metadata and 
variables are always
+   * project-wide even when the GUI location also reports them.
    *
    * @param searchable the searchable to classify
    * @param sourceByKey the source-location map from {@link #enumerateAll}
-   * @return true if this is an open pipeline/workflow tab
+   * @return true if this is an open file tab
    */
   public static boolean isOpenObject(ISearchable searchable, Map<String, 
Integer> sourceByKey) {
     Object object = searchable.getSearchableObject();
-    if (object instanceof PipelineMeta || object instanceof WorkflowMeta) {
+    if (object instanceof PipelineMeta
+        || object instanceof WorkflowMeta
+        || object instanceof TextFileContent) {
       Integer source = sourceByKey.get(searchableKey(searchable));
       return source != null && source == 0;
     }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiTextFileSearchable.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiTextFileSearchable.java
new file mode 100644
index 0000000000..c0a3a031b7
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiTextFileSearchable.java
@@ -0,0 +1,100 @@
+/*
+ * 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.search;
+
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableCallback;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+
+/** An {@link ISearchable} for a text-based explorer file (SQL, JSON, plain 
text, …). */
+public class HopGuiTextFileSearchable implements ISearchable<TextFileContent> {
+
+  private final String location;
+  private final String type;
+  private final TextFileContent content;
+
+  public HopGuiTextFileSearchable(String location, String type, 
TextFileContent content) {
+    this.location = location;
+    this.type = type;
+    this.content = content;
+  }
+
+  @Override
+  public String getLocation() {
+    return location;
+  }
+
+  @Override
+  public String getName() {
+    String filename = content.getFilename();
+    if (filename == null || filename.isEmpty()) {
+      return "";
+    }
+    try {
+      FileObject fileObject = HopVfs.getFileObject(filename);
+      FileName fileName = fileObject.getName();
+      return fileName.getBaseName();
+    } catch (Exception e) {
+      int slash = Math.max(filename.lastIndexOf('/'), 
filename.lastIndexOf('\\'));
+      return slash >= 0 ? filename.substring(slash + 1) : filename;
+    }
+  }
+
+  @Override
+  public String getType() {
+    return type;
+  }
+
+  @Override
+  public String getFilename() {
+    return content.getFilename();
+  }
+
+  @Override
+  public TextFileContent getSearchableObject() {
+    return content;
+  }
+
+  @Override
+  public ISearchableCallback getSearchCallback() {
+    return (searchable, searchResult) -> {
+      String filename = content.getFilename();
+      if (filename == null || filename.isEmpty()) {
+        return;
+      }
+      try {
+        ExplorerPerspective perspective = HopGui.getExplorerPerspective();
+        IHopFileTypeHandler fileTypeHandler = 
perspective.findFileTypeHandlerByFilename(filename);
+        if (fileTypeHandler != null) {
+          perspective.setActiveFileTypeHandler(fileTypeHandler);
+        } else {
+          HopGui.getInstance().fileDelegate.fileOpen(filename);
+        }
+        perspective.activate();
+      } catch (Exception e) {
+        throw new HopException("Error opening searchable text file: " + 
filename, e);
+      }
+    };
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContent.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContent.java
new file mode 100644
index 0000000000..9531f11902
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContent.java
@@ -0,0 +1,41 @@
+/*
+ * 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.search;
+
+/**
+ * Content of a text-based explorer file, used as the searchable object for 
text/SQL/JSON/XML and
+ * similar file types.
+ */
+public class TextFileContent {
+
+  private final String filename;
+  private final String content;
+
+  public TextFileContent(String filename, String content) {
+    this.filename = filename;
+    this.content = content == null ? "" : content;
+  }
+
+  public String getFilename() {
+    return filename;
+  }
+
+  public String getContent() {
+    return content;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
new file mode 100644
index 0000000000..d75f8efb52
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
@@ -0,0 +1,61 @@
+/*
+ * 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.search;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.search.BaseSearchableAnalyser;
+import org.apache.hop.core.search.ISearchQuery;
+import org.apache.hop.core.search.ISearchResult;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableAnalyser;
+import org.apache.hop.core.search.SearchableAnalyserPlugin;
+
+@SearchableAnalyserPlugin(
+    id = "TextFileContentSearchableAnalyser",
+    name = "Search in a text file",
+    description = "Search content of text-based explorer files")
+public class TextFileContentSearchableAnalyser extends 
BaseSearchableAnalyser<TextFileContent>
+    implements ISearchableAnalyser<TextFileContent> {
+
+  @Override
+  public Class<TextFileContent> getSearchableClass() {
+    return TextFileContent.class;
+  }
+
+  @Override
+  public List<ISearchResult> search(
+      ISearchable<TextFileContent> searchable, ISearchQuery searchQuery) {
+    TextFileContent textFileContent = searchable.getSearchableObject();
+    List<ISearchResult> results = new ArrayList<>();
+
+    String content = textFileContent.getContent();
+    if (content == null || content.isEmpty()) {
+      return results;
+    }
+
+    String[] lines = content.split("\\R", -1);
+    for (int i = 0; i < lines.length; i++) {
+      String line = lines[i];
+      String lineNumber = String.valueOf(i + 1);
+      matchProperty(searchable, results, searchQuery, "line " + lineNumber, 
line, lineNumber);
+    }
+
+    return results;
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java
index 0e8bab1136..c6328f9b95 100644
--- 
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java
@@ -60,6 +60,16 @@ class HopGuiSearchHelperTest {
     return searchable;
   }
 
+  /** A text-file searchable (searchable object is TextFileContent so it can 
be "open"). */
+  private static ISearchable textFile(String name, String filename) {
+    ISearchable searchable = mock(ISearchable.class);
+    when(searchable.getName()).thenReturn(name);
+    when(searchable.getType()).thenReturn("SQL File");
+    when(searchable.getFilename()).thenReturn(filename);
+    when(searchable.getSearchableObject()).thenReturn(new 
TextFileContent(filename, "select 1"));
+    return searchable;
+  }
+
   private static ISearchResult nameMatch(ISearchable searchable) {
     return new SearchResult(searchable, searchable.getName(), "name", null, 
searchable.getName());
   }
@@ -107,6 +117,27 @@ class HopGuiSearchHelperTest {
     assertFalse(HopGuiSearchHelper.isOpenObject(meta, source(meta, 0)));
   }
 
+  @Test
+  void openTextFileGoesToOpenSection() {
+    ISearchable open = textFile("query.sql", "/p/query.sql");
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(nameMatch(open)), source(open, 
0));
+
+    assertEquals(1, sections.size());
+    assertEquals(HopGuiSearchHelper.SECTION_OPEN, sections.get(0).getKey());
+    assertTrue(HopGuiSearchHelper.isOpenObject(open, source(open, 0)));
+  }
+
+  @Test
+  void projectTextFileGoesToProjectSection() {
+    ISearchable onDisk = textFile("query.sql", "/p/query.sql");
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(nameMatch(onDisk)), 
source(onDisk, 1));
+
+    assertEquals(HopGuiSearchHelper.SECTION_PROJECT, sections.get(0).getKey());
+    assertFalse(HopGuiSearchHelper.isOpenObject(onDisk, source(onDisk, 1)));
+  }
+
   @Test
   void nameMatchMarksObjectButAddsNoChildRow() {
     ISearchable open = pipeline("loader", "/p/loader.hpl");
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
new file mode 100644
index 0000000000..484bdb8b9d
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.search;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.search.ISearchResult;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableCallback;
+import org.apache.hop.core.search.SearchQuery;
+import org.junit.jupiter.api.Test;
+
+class TextFileContentSearchableAnalyserTest {
+
+  @Test
+  void matchesLinesAndReportsLineNumbers() {
+    TextFileContent content =
+        new TextFileContent("/project/query.sql", "select 1;\nselect foo from 
bar;\nselect 2;");
+    ISearchable<TextFileContent> searchable =
+        new HopGuiTextFileSearchable("test", "SQL File", content);
+
+    TextFileContentSearchableAnalyser analyser = new 
TextFileContentSearchableAnalyser();
+    List<ISearchResult> results = analyser.search(searchable, new 
SearchQuery("foo", true, false));
+
+    assertEquals(1, results.size());
+    assertEquals("2", results.get(0).getComponent());
+    assertTrue(results.get(0).getMatchingString().contains("foo"));
+  }
+
+  @Test
+  void emptyContentReturnsNoResults() {
+    TextFileContent content = new TextFileContent("/project/empty.txt", "");
+    ISearchable<TextFileContent> searchable =
+        new ISearchable<>() {
+          @Override
+          public String getLocation() {
+            return "test";
+          }
+
+          @Override
+          public String getName() {
+            return "empty.txt";
+          }
+
+          @Override
+          public String getType() {
+            return "TXT File";
+          }
+
+          @Override
+          public String getFilename() {
+            return content.getFilename();
+          }
+
+          @Override
+          public TextFileContent getSearchableObject() {
+            return content;
+          }
+
+          @Override
+          public ISearchableCallback getSearchCallback() {
+            return null;
+          }
+        };
+
+    TextFileContentSearchableAnalyser analyser = new 
TextFileContentSearchableAnalyser();
+    assertTrue(analyser.search(searchable, new SearchQuery("anything", true, 
false)).isEmpty());
+  }
+}


Reply via email to