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

bamaer 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 61a4a54227 Issue #6146 : Search all configured projects from the 
project menu (#8702)
61a4a54227 is described below

commit 61a4a542273d0de5a330bbb83981111febd69d76
Author: Matt Casters <[email protected]>
AuthorDate: Fri Oct 2 06:52:27 2026 +0200

    Issue #6146 : Search all configured projects from the project menu (#8702)
    
    * Issue #6146 : Search all configured projects from the project menu
    
    * Issue #6146 : Search only allowed projects and open hits in their own 
project
    
    All-projects search keeps the project allow-list captured on the UI thread.
    The active project and hop-search stay on the active environment. Variable
    hits use the resolved configuration file, and opening a hit from another
    project asks to switch there first.
---
 .../hop/core/search/ISearchablesLocation.java      |  19 +
 .../ROOT/pages/hop-gui/perspective-search.adoc     |   6 +-
 .../ROOT/pages/projects/projects-environments.adoc |   2 +
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java |  17 +
 ...dProjectsSearchablesLocationExtensionPoint.java |  23 +-
 .../search/AllProjectsSearchablesIterator.java     | 119 +++++++
 .../search/AllProjectsSearchablesLocation.java     | 110 ++++++
 .../projects/search/ProjectScopedSearchable.java   | 146 ++++++++
 .../hop/projects/search/ProjectSearchableOpen.java |  85 +++++
 .../search/ProjectSearchablesIterator.java         | 101 +++++-
 .../gui/messages/messages_en_US.properties         |   2 +
 .../search/messages/messages_en_US.properties      |  19 +
 .../search/AllProjectsSearchablesLocationTest.java | 393 +++++++++++++++++++++
 .../projects/search/ProjectSearchableOpenTest.java | 218 ++++++++++++
 .../hop/ui/hopgui/search/HopGuiSearchHelper.java   |  76 +++-
 .../ui/hopgui/search/HopGuiSearchResultsPanel.java | 147 ++++++--
 .../ui/hopgui/search/SearchEverywhereDialog.java   | 154 ++++++--
 .../search/messages/messages_en_US.properties      |   2 +
 .../ui/hopgui/search/HopGuiSearchHelperTest.java   |  85 +++++
 19 files changed, 1654 insertions(+), 70 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/search/ISearchablesLocation.java 
b/core/src/main/java/org/apache/hop/core/search/ISearchablesLocation.java
index 5baedb011f..70c69b5e5e 100644
--- a/core/src/main/java/org/apache/hop/core/search/ISearchablesLocation.java
+++ b/core/src/main/java/org/apache/hop/core/search/ISearchablesLocation.java
@@ -26,6 +26,25 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
 public interface ISearchablesLocation {
   String getLocationDescription();
 
+  /**
+   * Stable id used to select this location again. Defaults to the description.
+   *
+   * @return the location id
+   */
+  default String getLocationId() {
+    return getLocationDescription();
+  }
+
+  /**
+   * Whether the combined search of loaded locations includes this location. A 
location that walks
+   * every configured project returns false and is searched only when the user 
selects it.
+   *
+   * @return true to include this location in the default combined search
+   */
+  default boolean isIncludedInDefaultSearch() {
+    return true;
+  }
+
   Iterator<ISearchable> getSearchables(IHopMetadataProvider metadataProvider, 
IVariables variables)
       throws HopException;
 }
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
index 85d780c3a3..26b99f159f 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
@@ -41,6 +41,10 @@ Open search with `CTRL-F` or *Edit → Find*. Type in the 
search field to filter
 * `.*` — treat the query as a regular expression
 * image:icons/gear.svg[width="16px"] — open *Search settings* (limits and 
options; see below)
 
+The footer shows the status text and, at the bottom right, the search 
location. *All loaded locations* searches open files and the active project, 
including configuration files of the active environment only. Choose a single 
location to search only that one. *All projects* searches every configured 
project you are allowed to open, and the configuration files of each of its 
environments. Opening a hit from another project asks to switch to that project 
first.
+
+*Project → Search projects...* (the project indicator at the bottom left, 
under *Edit project...*) opens this dialog with *All projects* already selected.
+
 Results are grouped by *Open files* and *Project files*, then by type 
(Pipeline, Workflow, metadata type, and so on). Use the arrow keys to move, 
*Enter* to open the selection, and *Escape* to close the popup.
 
 *Show all results* hands the same query to a search tab in the bottom dock for 
deeper browsing (details panel, open selected item).
@@ -109,7 +113,7 @@ Settings are stored in the Hop configuration 
(`hop-config.json`) under the `sear
 
 == Full results panel
 
-From *Show all results*, or when Hop Gui opens a search tab for references, 
the bottom dock shows a toolbar (query, case/regex, refresh, settings), a 
grouped results tree, and a details side panel. Select a hit and use *Open* to 
jump to the matching object.
+From *Show all results*, or when Hop Gui opens a search tab for references, 
the bottom dock shows a toolbar (query, case/regex, refresh, settings), a 
grouped results tree, and a details side panel. The footer has the status text, 
the same search-location selector as the popup, and *Open*. Select a hit and 
use *Open* to jump to the matching object. *Show all results* keeps the 
location that was selected in the popup.
 
 Refresh reloads searchable objects from disk and open tabs after you change 
project files outside Hop Gui.
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index 7d5b61fbcc..d23aa57c94 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
@@ -263,6 +263,8 @@ 
image:hop-gui/environment/switch-environment-list.png[Switch environment]
 
 Click the project indicator at the bottom left, then choose *Edit project...* 
to re-open the project properties dialog described above.
 
+Choose *Search projects...* from the same menu to search pipelines, workflows, 
metadata and variables across every configured project and environment, not 
only the project that is open. A project the current user is not allowed to 
open is left out. Opening a result from another project asks to switch to that 
project before the file is opened. The active project search (Ctrl+F and 
hop-search) still uses only the active environment's configuration files.
+
 Choose *Delete project...* from the same menu to remove the project from the 
Hop configuration.
 
 == Edit, duplicate and delete environments
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 9d81c1e0a8..9db4114a1d 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -75,6 +75,7 @@ import 
org.apache.hop.projects.environment.LifecycleEnvironmentDialog;
 import org.apache.hop.projects.project.Project;
 import org.apache.hop.projects.project.ProjectConfig;
 import org.apache.hop.projects.project.ProjectDialog;
+import org.apache.hop.projects.search.AllProjectsSearchablesLocation;
 import org.apache.hop.projects.security.ProjectsAccessControl;
 import org.apache.hop.projects.security.ProjectsSecurityTab;
 import org.apache.hop.projects.util.ProjectsUtil;
@@ -94,6 +95,7 @@ import org.apache.hop.ui.core.widget.FileTree;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.apache.hop.ui.hopgui.search.SearchEverywhereDialog;
 import org.apache.hop.ui.hopgui.vfs.explorer.VfsFileExplorerLocation;
 import org.apache.hop.ui.pipeline.dialog.PipelineExecutionConfigurationDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
@@ -128,6 +130,7 @@ public class ProjectsGuiPlugin {
   public static final String ID_CONTEXT_MENU_PROJECT_ADD_FROM_TEMPLATE =
       "context-menu-project-40013-add-from-template";
   public static final String ID_CONTEXT_MENU_PROJECT_EDIT = 
"context-menu-project-40020-edit";
+  public static final String ID_CONTEXT_MENU_PROJECT_SEARCH = 
"context-menu-project-40025-search";
   public static final String ID_CONTEXT_MENU_PROJECT_DELETE = 
"context-menu-project-40030-delete";
 
   public static final String ID_TOOLBAR_ITEM_ENVIRONMENT = 
"toolbar-item-20000-environment";
@@ -793,6 +796,20 @@ public class ProjectsGuiPlugin {
     }
   }
 
+  @GuiMenuElement(
+      root = ID_CONTEXT_MENU_PROJECT,
+      parentId = ID_CONTEXT_MENU_PROJECT,
+      id = ID_CONTEXT_MENU_PROJECT_SEARCH,
+      label = "i18n::HopGui.Toolbar.Project.Search.Label",
+      toolTip = "i18n::HopGui.Toolbar.Project.Search.Tooltip",
+      image = "ui/images/search.svg")
+  public void searchProjects() {
+    HopGui hopGui = HopGui.getInstance();
+    new SearchEverywhereDialog(
+            hopGui.getActiveShell(), hopGui, 
AllProjectsSearchablesLocation.LOCATION_ID)
+        .open();
+  }
+
   private static boolean askAboutProjectRefresh(HopGui hopGui) {
     MessageBox box = new MessageBox(hopGui.getShell(), SWT.ICON_QUESTION | 
SWT.YES | SWT.NO);
     box.setText(BaseMessages.getString(PKG, 
"ProjectGuiPlugin.ReloadProject.Dialog.Header"));
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AddProjectsSearchablesLocationExtensionPoint.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AddProjectsSearchablesLocationExtensionPoint.java
index c5bd8fb3bd..e0d59dd3d0 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AddProjectsSearchablesLocationExtensionPoint.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AddProjectsSearchablesLocationExtensionPoint.java
@@ -19,6 +19,7 @@ package org.apache.hop.projects.search;
 
 import java.util.List;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.extension.ExtensionPoint;
 import org.apache.hop.core.extension.IExtensionPoint;
 import org.apache.hop.core.logging.ILogChannel;
@@ -39,9 +40,27 @@ public class AddProjectsSearchablesLocationExtensionPoint
       ILogChannel log, IVariables variables, List<ISearchablesLocation> 
searchablesLocations)
       throws HopException {
 
-    // The location to add is the currently active project and the files in 
the home folder
+    addActiveProjectLocation(log, searchablesLocations);
+
+    // Searched only when the user picks it. It is not part of the default 
combined search.
+    // The allow-list is captured here, on the thread that builds locations 
(the UI thread). Search
+    // itself runs later on a background thread and must not resolve the 
security context again.
+    //
+    searchablesLocations.add(
+        new 
AllProjectsSearchablesLocation(AllProjectsSearchablesLocation.allowedProjectNames()));
+  }
+
+  private void addActiveProjectLocation(
+      ILogChannel log, List<ISearchablesLocation> searchablesLocations) {
+    // The location to add is the currently active project and the files in 
the home folder.
+    // A missing namespace must not drop the all-projects location added by 
the caller.
     //
-    String projectName = HopNamespace.getNamespace();
+    String projectName;
+    try {
+      projectName = HopNamespace.getNamespace();
+    } catch (HopRuntimeException e) {
+      return;
+    }
     if (projectName == null) {
       return;
     }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AllProjectsSearchablesIterator.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AllProjectsSearchablesIterator.java
new file mode 100644
index 0000000000..3f24d0634c
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AllProjectsSearchablesIterator.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.search;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+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.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataUtil;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.Project;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper;
+
+/**
+ * Searchables from the projects named in the allow-list. A project that 
cannot be loaded is skipped
+ * so the others are still searched. Nothing here enables a project: the GUI 
keeps its active
+ * metadata and VFS providers.
+ *
+ * <p>The allow-list is the one captured on the UI thread. This iterator does 
not ask {@code
+ * ProjectsAccessControl} again, because search runs on a background thread.
+ */
+public class AllProjectsSearchablesIterator implements Iterator<ISearchable> {
+
+  private final List<ISearchable> searchables;
+  private final Iterator<ISearchable> iterator;
+
+  public AllProjectsSearchablesIterator(IVariables variables, List<String> 
allowedProjectNames) {
+    this.searchables = new ArrayList<>();
+    Set<String> seen = new HashSet<>();
+
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    List<ProjectConfig> projects =
+        config == null || config.getProjectConfigurations() == null
+            ? List.of()
+            : new ArrayList<>(config.getProjectConfigurations());
+    for (ProjectConfig projectConfig : projects) {
+      if (projectConfig == null || projectConfig.getProjectName() == null) {
+        continue;
+      }
+      if (!isAllowed(allowedProjectNames, projectConfig.getProjectName())) {
+        continue;
+      }
+      try {
+        collectProject(variables, projectConfig, seen);
+      } catch (Exception e) {
+        LogChannel.GENERAL.logError(
+            "Error loading searchables for project '" + 
projectConfig.getProjectName() + "'", e);
+      }
+    }
+    this.iterator = searchables.iterator();
+  }
+
+  /** A null list allows every project. An empty list allows none. Comparison 
ignores case. */
+  static boolean isAllowed(List<String> allowedProjectNames, String 
projectName) {
+    if (allowedProjectNames == null) {
+      return true;
+    }
+    for (String allowed : allowedProjectNames) {
+      if (allowed != null && allowed.equalsIgnoreCase(projectName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private void collectProject(IVariables variables, ProjectConfig 
projectConfig, Set<String> seen)
+      throws HopException {
+    IVariables projectVariables = new Variables();
+    projectVariables.initializeFrom(variables);
+    Project project = projectConfig.loadProject(projectVariables);
+    // Project home and metadata folder only. Environment files are searched 
as variables, not
+    // applied over each other (the last environment would otherwise hide the 
others).
+    project.modifyVariables(projectVariables, projectConfig, new 
ArrayList<>(), null);
+    IHopMetadataProvider metadataProvider =
+        HopMetadataUtil.getStandardHopMetadataProvider(projectVariables);
+    Iterator<ISearchable> projectSearchables =
+        new ProjectSearchablesIterator(metadataProvider, projectVariables, 
projectConfig, true);
+    while (projectSearchables.hasNext()) {
+      ISearchable searchable = projectSearchables.next();
+      if (seen.add(HopGuiSearchHelper.searchableKey(searchable))) {
+        searchables.add(searchable);
+      }
+    }
+  }
+
+  @Override
+  public boolean hasNext() {
+    return iterator.hasNext();
+  }
+
+  @Override
+  public ISearchable next() {
+    return iterator.next();
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AllProjectsSearchablesLocation.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AllProjectsSearchablesLocation.java
new file mode 100644
index 0000000000..5d1c4dbe50
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/AllProjectsSearchablesLocation.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.search;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchablesLocation;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.security.ProjectsAccessControl;
+
+/**
+ * Search location over every configured project the current user may open, 
and the configuration
+ * files of each of those projects' environments.
+ *
+ * <p>The allow-list is fixed when the location is built. Search runs later on 
a background thread,
+ * where a session-bound security context may no longer resolve, so {@link
+ * ProjectsAccessControl#isProjectAllowed} must not be called again from there.
+ */
+public class AllProjectsSearchablesLocation implements ISearchablesLocation {
+
+  public static final String LOCATION_ID = "all-projects";
+
+  public static final String DESCRIPTION = "All projects";
+
+  private final List<String> allowedProjectNames;
+
+  /** Allow-list captured now. Call this on the UI thread, where the security 
context is bound. */
+  public AllProjectsSearchablesLocation() {
+    this(allowedProjectNames());
+  }
+
+  /**
+   * @param allowedProjectNames project names captured on the UI thread; not 
null. An empty list
+   *     searches no project.
+   */
+  public AllProjectsSearchablesLocation(List<String> allowedProjectNames) {
+    this.allowedProjectNames =
+        List.copyOf(allowedProjectNames == null ? allowedProjectNames() : 
allowedProjectNames);
+  }
+
+  /**
+   * Project names the current session may search. Call this on the UI thread 
and pass the result
+   * into {@link #AllProjectsSearchablesLocation(List)}.
+   */
+  public static List<String> allowedProjectNames() {
+    List<String> allowed = new ArrayList<>();
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    if (config == null || config.getProjectConfigurations() == null) {
+      return allowed;
+    }
+    for (ProjectConfig projectConfig : config.getProjectConfigurations()) {
+      if (projectConfig == null || 
StringUtils.isEmpty(projectConfig.getProjectName())) {
+        continue;
+      }
+      if 
(ProjectsAccessControl.isProjectAllowed(projectConfig.getProjectName())) {
+        allowed.add(projectConfig.getProjectName());
+      }
+    }
+    return allowed;
+  }
+
+  public List<String> getAllowedProjectNames() {
+    return allowedProjectNames;
+  }
+
+  @Override
+  public String getLocationDescription() {
+    return DESCRIPTION;
+  }
+
+  @Override
+  public String getLocationId() {
+    return LOCATION_ID;
+  }
+
+  @Override
+  public boolean isIncludedInDefaultSearch() {
+    return false;
+  }
+
+  @Override
+  public Iterator<ISearchable> getSearchables(
+      IHopMetadataProvider metadataProvider, IVariables variables) throws 
HopException {
+    // metadataProvider belongs to the active project. Each allowed project is 
loaded on its own.
+    return new AllProjectsSearchablesIterator(variables, allowedProjectNames);
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectScopedSearchable.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectScopedSearchable.java
new file mode 100644
index 0000000000..77c6e72a27
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectScopedSearchable.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.search;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableCallback;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.projects.gui.ProjectsGuiPlugin;
+import org.apache.hop.projects.util.Defaults;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.eclipse.swt.SWT;
+
+/**
+ * Searchable that belongs to one project. Opening a hit from another project 
asks to switch there
+ * first and does not open it in the project that is active now.
+ */
+final class ProjectScopedSearchable implements ISearchable<Object> {
+
+  private static final Class<?> PKG = ProjectScopedSearchable.class;
+
+  private final ISearchable<?> delegate;
+  private final String projectName;
+  private final ProjectSearchableOpen.Actions actions;
+
+  private ProjectScopedSearchable(
+      ISearchable<?> delegate, String projectName, 
ProjectSearchableOpen.Actions actions) {
+    this.delegate = delegate;
+    this.projectName = projectName;
+    this.actions = actions;
+  }
+
+  static ISearchable<?> wrap(ISearchable<?> searchable, String projectName) {
+    if (searchable == null || StringUtils.isEmpty(projectName)) {
+      return searchable;
+    }
+    return forProject(searchable, projectName, HOP_GUI);
+  }
+
+  static ISearchable<?> forProject(
+      ISearchable<?> searchable, String projectName, 
ProjectSearchableOpen.Actions actions) {
+    return new ProjectScopedSearchable(searchable, projectName, actions);
+  }
+
+  @Override
+  public String getLocation() {
+    return delegate.getLocation();
+  }
+
+  @Override
+  public String getName() {
+    return delegate.getName();
+  }
+
+  @Override
+  public String getType() {
+    return delegate.getType();
+  }
+
+  @Override
+  public String getFilename() {
+    return delegate.getFilename();
+  }
+
+  @Override
+  public Object getSearchableObject() {
+    return delegate.getSearchableObject();
+  }
+
+  @Override
+  public ISearchableCallback getSearchCallback() {
+    return (searchable, searchResult) ->
+        ProjectSearchableOpen.prepare(
+            projectName,
+            actions.activeProjectName(),
+            () -> actions.confirmSwitch(projectName),
+            actions::switchToProject,
+            () -> {
+              ISearchableCallback callback = delegate.getSearchCallback();
+              callback.callback(delegate, searchResult);
+            });
+  }
+
+  /** Question box and project switch used by the GUI. */
+  static final ProjectSearchableOpen.Actions HOP_GUI =
+      new ProjectSearchableOpen.Actions() {
+        @Override
+        public String activeProjectName() {
+          try {
+            return HopNamespace.getNamespace();
+          } catch (RuntimeException e) {
+            return null;
+          }
+        }
+
+        @Override
+        public boolean confirmSwitch(String projectName) {
+          HopGui hopGui = HopGui.getInstance();
+          MessageBox box = new MessageBox(hopGui.getShell(), SWT.ICON_QUESTION 
| SWT.YES | SWT.NO);
+          box.setText(BaseMessages.getString(PKG, 
"ProjectSearch.SwitchProject.Title"));
+          box.setMessage(
+              BaseMessages.getString(PKG, 
"ProjectSearch.SwitchProject.Message", projectName));
+          return (box.open() & SWT.YES) != 0;
+        }
+
+        @Override
+        public boolean switchToProject(String projectName) {
+          try {
+            new ProjectsGuiPlugin().selectProject(projectName);
+          } catch (Exception e) {
+            LogChannel.GENERAL.logError("Error switching to project '" + 
projectName + "'", e);
+            return false;
+          }
+          String active = activeProjectName();
+          if (active == null || !active.equalsIgnoreCase(projectName)) {
+            return false;
+          }
+          // selectProjectInUiOnly sets the namespace when the project could 
not be loaded. That is
+          // not an enabled project: its variables still belong to the project 
that was active.
+          HopGui hopGui = HopGui.getInstance();
+          if (hopGui == null || hopGui.getVariables() == null) {
+            return false;
+          }
+          String enabled = 
hopGui.getVariables().getVariable(Defaults.VARIABLE_HOP_PROJECT_NAME);
+          return 
projectName.equalsIgnoreCase(StringUtils.defaultString(enabled));
+        }
+      };
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchableOpen.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchableOpen.java
new file mode 100644
index 0000000000..a95eadd4a7
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/search/ProjectSearchableOpen.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.search;
+
+import java.util.function.BooleanSupplier;
+import java.util.function.Predicate;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.exception.HopException;
+
+/**
+ * Decides whether a search hit may be opened in the project that is active 
now. A hit from another
+ * project is never opened there: the caller switches first, or does not open 
it.
+ */
+final class ProjectSearchableOpen {
+
+  private ProjectSearchableOpen() {}
+
+  /**
+   * UI actions the open path needs. Tests supply a fake; the GUI supplies the 
dialog and switch.
+   */
+  interface Actions {
+    String activeProjectName();
+
+    boolean confirmSwitch(String projectName);
+
+    /**
+     * @return true only when the named project is now the enabled project
+     */
+    boolean switchToProject(String projectName);
+  }
+
+  static boolean isOtherProject(String hitProject, String activeProject) {
+    if (StringUtils.isEmpty(hitProject)) {
+      return false;
+    }
+    return 
!hitProject.equalsIgnoreCase(StringUtils.defaultString(activeProject));
+  }
+
+  /**
+   * Opens a hit in its own project.
+   *
+   * @return true when the hit was opened
+   */
+  static boolean prepare(
+      String hitProject,
+      String activeProject,
+      BooleanSupplier confirm,
+      Predicate<String> switchProject,
+      OpenAction open)
+      throws HopException {
+    if (!isOtherProject(hitProject, activeProject)) {
+      open.run();
+      return true;
+    }
+    if (!confirm.getAsBoolean()) {
+      return false;
+    }
+    // Switch before open. A failed switch must not open the hit in the active 
project.
+    if (!switchProject.test(hitProject)) {
+      return false;
+    }
+    open.run();
+    return true;
+  }
+
+  @FunctionalInterface
+  interface OpenAction {
+    void run() throws HopException;
+  }
+}
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 938f98cdee..0dec19c71a 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
@@ -24,6 +24,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.config.DescribedVariablesConfigFile;
 import org.apache.hop.core.config.HopConfig;
@@ -40,6 +41,7 @@ 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.projects.util.Defaults;
 import org.apache.hop.ui.hopgui.file.HopFileTypeRegistry;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.search.HopGuiDescribedVariableSearchable;
@@ -53,21 +55,30 @@ public class ProjectSearchablesIterator implements 
Iterator<ISearchable> {
   private List<ISearchable> searchables;
   private Iterator<ISearchable> iterator;
 
+  /**
+   * Searchables of one project. Configuration files come from the active 
environment only ({@code
+   * HOP_ENVIRONMENT_NAME}). All-projects search passes {@code 
searchAllEnvironments} so every
+   * environment of that project is included.
+   */
   public ProjectSearchablesIterator(
       IHopMetadataProvider metadataProvider, IVariables variables, 
ProjectConfig projectConfig)
       throws HopException {
+    this(metadataProvider, variables, projectConfig, false);
+  }
+
+  public ProjectSearchablesIterator(
+      IHopMetadataProvider metadataProvider,
+      IVariables variables,
+      ProjectConfig projectConfig,
+      boolean searchAllEnvironments)
+      throws HopException {
     this.projectConfig = projectConfig;
     this.searchables = new ArrayList<>();
 
-    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
-
     try {
-      List<String> configurationFiles = new ArrayList<>();
-      List<LifecycleEnvironment> environments =
-          config.findEnvironmentsOfProject(projectConfig.getProjectName());
-      if (!environments.isEmpty()) {
-        configurationFiles.addAll(environments.get(0).getConfigurationFiles());
-      }
+      List<String> configurationFiles =
+          environmentConfigurationFiles(
+              projectConfig.getProjectName(), variables, 
searchAllEnvironments);
 
       // Discover files via registered hop file types that opt into search.
       //
@@ -117,9 +128,14 @@ public class ProjectSearchablesIterator implements 
Iterator<ISearchable> {
               continue;
             }
             ISearchable searchable =
-                fileType.createSearchable(filePath, "Project file", variables, 
metadataProvider);
+                fileType.createSearchable(
+                    filePath,
+                    "Project " + projectConfig.getProjectName(),
+                    variables,
+                    metadataProvider);
             if (searchable != null) {
-              searchables.add(searchable);
+              searchables.add(
+                  ProjectScopedSearchable.wrap(searchable, 
projectConfig.getProjectName()));
             }
           } catch (Exception e) {
             LogChannel.GENERAL.logError("Error loading searchable file: " + 
filePath, e);
@@ -137,7 +153,7 @@ public class ProjectSearchablesIterator implements 
Iterator<ISearchable> {
           HopGuiMetadataSearchable searchable =
               new HopGuiMetadataSearchable(
                   metadataProvider, serializer, hopMetadata, 
serializer.getManagedClass());
-          searchables.add(searchable);
+          searchables.add(ProjectScopedSearchable.wrap(searchable, 
projectConfig.getProjectName()));
         }
       }
 
@@ -158,8 +174,12 @@ public class ProjectSearchablesIterator implements 
Iterator<ISearchable> {
               new DescribedVariablesConfigFile(realConfigurationFile);
           configFile.readFromFile();
           for (DescribedVariable describedVariable : 
configFile.getDescribedVariables()) {
+            // The resolved path, not the raw ${PROJECT_HOME}/... value. The 
click handler resolves
+            // again with whichever project is active, and the searchable key 
uses this filename.
             searchables.add(
-                new HopGuiDescribedVariableSearchable(describedVariable, 
configurationFile));
+                ProjectScopedSearchable.wrap(
+                    new HopGuiDescribedVariableSearchable(describedVariable, 
realConfigurationFile),
+                    projectConfig.getProjectName()));
           }
         }
       }
@@ -171,6 +191,63 @@ public class ProjectSearchablesIterator implements 
Iterator<ISearchable> {
     }
   }
 
+  /**
+   * Configuration files to search. All-projects search includes every 
environment of the project.
+   * The active project and hop-search include only the active environment 
({@code
+   * HOP_ENVIRONMENT_NAME}), and only when that environment belongs to this 
project. Duplicate paths
+   * are skipped. Order follows the environment list.
+   */
+  static List<String> environmentConfigurationFiles(
+      String projectName, IVariables variables, boolean allEnvironments) {
+    List<String> configurationFiles = new ArrayList<>();
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    if (config == null || projectName == null) {
+      return configurationFiles;
+    }
+    List<LifecycleEnvironment> environments;
+    if (allEnvironments) {
+      environments = config.findEnvironmentsOfProject(projectName);
+    } else {
+      environments = new ArrayList<>();
+      String environmentName = activeEnvironmentName(variables);
+      if (environmentName != null) {
+        LifecycleEnvironment environment = 
config.findEnvironment(environmentName);
+        if (environment != null
+            && projectName.equalsIgnoreCase(
+                StringUtils.defaultString(environment.getProjectName()))) {
+          environments.add(environment);
+        }
+      }
+    }
+    for (LifecycleEnvironment environment : environments) {
+      addConfigurationFiles(configurationFiles, environment);
+    }
+    return configurationFiles;
+  }
+
+  private static String activeEnvironmentName(IVariables variables) {
+    if (variables == null) {
+      return null;
+    }
+    String environmentName = 
variables.getVariable(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME);
+    return StringUtils.isEmpty(environmentName) ? null : environmentName;
+  }
+
+  private static void addConfigurationFiles(
+      List<String> configurationFiles, LifecycleEnvironment environment) {
+    if (environment == null || environment.getConfigurationFiles() == null) {
+      return;
+    }
+    for (String configurationFile : environment.getConfigurationFiles()) {
+      if (configurationFile == null
+          || configurationFile.isEmpty()
+          || configurationFiles.contains(configurationFile)) {
+        continue;
+      }
+      configurationFiles.add(configurationFile);
+    }
+  }
+
   @Override
   public boolean hasNext() {
     return iterator.hasNext();
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
index 0e1c909b72..4e2e758bd0 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/gui/messages/messages_en_US.properties
@@ -42,6 +42,8 @@ HopGui.Toolbar.Project.Delete.Label=Delete project...
 HopGui.Toolbar.Project.Delete.Tooltip=Delete the selected project
 HopGui.Toolbar.Project.Edit.Label=Edit project...
 HopGui.Toolbar.Project.Edit.Tooltip=Edit the selected project
+HopGui.Toolbar.Project.Search.Label=Search projects...
+HopGui.Toolbar.Project.Search.Tooltip=Search pipelines, workflows, metadata 
and variables in every project you are allowed to open
 HopGui.Toolbar.Project.Select.Tooltip=More projects...
 ProjectGuiPlugin.AddEnvironment.Error.Dialog.Header=Error
 ProjectGuiPlugin.AddEnvironment.Error.Dialog.Message=Error adding lifecycle 
environment
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/search/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/search/messages/messages_en_US.properties
new file mode 100644
index 0000000000..f1be1b77c7
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/search/messages/messages_en_US.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+
+ProjectSearch.SwitchProject.Title=Switch project?
+ProjectSearch.SwitchProject.Message=This item belongs to project ''{0}'', not 
the active project. Switch to ''{0}'' and open it there?
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/search/AllProjectsSearchablesLocationTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/search/AllProjectsSearchablesLocationTest.java
new file mode 100644
index 0000000000..725b0e9d80
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/search/AllProjectsSearchablesLocationTest.java
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.search;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.hop.core.config.ConfigFileSerializer;
+import org.apache.hop.core.config.DescribedVariablesConfigFile;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchablesLocation;
+import org.apache.hop.core.security.HopRole;
+import org.apache.hop.core.security.HopSecurity;
+import org.apache.hop.core.security.HopSecurityContext;
+import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.util.HopMetadataUtil;
+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.projects.security.ProjectsAccessConfig;
+import org.apache.hop.projects.security.ProjectsAccessRule;
+import org.apache.hop.projects.util.Defaults;
+import org.apache.hop.projects.util.ProjectsUtil;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AllProjectsSearchablesLocationTest {
+
+  private final List<String> projectNames = new ArrayList<>();
+  private final List<String> environmentNames = new ArrayList<>();
+  private Path tempRoot;
+  private String previousNamespace;
+  private List<ProjectConfig> previousProjects;
+  private List<LifecycleEnvironment> previousEnvironments;
+
+  @BeforeEach
+  void setUp() {
+    HopSecurity.reset();
+    HopLogStore.init();
+    try {
+      previousNamespace = HopNamespace.getNamespace();
+    } catch (RuntimeException e) {
+      previousNamespace = null;
+    }
+    // Search reads the live configuration. Keep this test off the developer's 
projects.
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    previousProjects = new ArrayList<>(safeProjects(config));
+    previousEnvironments = new ArrayList<>(safeEnvironments(config));
+    safeProjects(config).clear();
+    safeEnvironments(config).clear();
+  }
+
+  @AfterEach
+  void tearDown() throws Exception {
+    HopNamespace.setNamespace(previousNamespace);
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    safeProjects(config).clear();
+    safeProjects(config).addAll(previousProjects);
+    safeEnvironments(config).clear();
+    safeEnvironments(config).addAll(previousEnvironments);
+    environmentNames.clear();
+    projectNames.clear();
+    HopSecurity.reset();
+    ProjectsAccessConfig.clearCache();
+    if (tempRoot != null && Files.exists(tempRoot)) {
+      try (Stream<Path> walk = Files.walk(tempRoot)) {
+        
walk.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+      }
+      tempRoot = null;
+    }
+  }
+
+  @Test
+  void searchesEveryProjectAndEveryEnvironment() throws Exception {
+    tempRoot = Files.createTempDirectory("hop-6146");
+    Path projectA = tempRoot.resolve("project-a");
+    Path projectB = tempRoot.resolve("project-b");
+    Files.createDirectories(projectA);
+    Files.createDirectories(projectB);
+
+    Path devFile = projectA.resolve("env-dev.json");
+    Path prodFile = projectA.resolve("env-prod.json");
+    Path betaFile = projectB.resolve("env-beta.json");
+    writeVariables(devFile, "ISSUE_6146_ALPHA", "from-dev");
+    writeVariables(prodFile, "ISSUE_6146_PROD", "from-prod");
+    writeVariables(betaFile, "ISSUE_6146_BETA", "from-beta");
+
+    registerProject("issue-6146-a", projectA);
+    registerProject("issue-6146-b", projectB);
+    registerProject("issue-6146-missing", tempRoot.resolve("does-not-exist"));
+    registerEnvironment("issue-6146-dev", "issue-6146-a", devFile);
+    registerEnvironment("issue-6146-prod", "issue-6146-a", prodFile);
+    registerEnvironment("issue-6146-beta", "issue-6146-b", betaFile);
+
+    AllProjectsSearchablesLocation location = new 
AllProjectsSearchablesLocation();
+    assertEquals(AllProjectsSearchablesLocation.LOCATION_ID, 
location.getLocationId());
+    assertEquals(AllProjectsSearchablesLocation.DESCRIPTION, 
location.getLocationDescription());
+    assertFalse(location.isIncludedInDefaultSearch());
+    assertTrue(HopGuiSearchHelper.selectLocations(List.of(location), 
0).isEmpty());
+    assertEquals(location, 
HopGuiSearchHelper.selectLocations(List.of(location), 1).get(0));
+
+    List<ISearchable> searchables = new ArrayList<>();
+    Iterator<ISearchable> iterator = location.getSearchables(null, new 
Variables());
+    while (iterator.hasNext()) {
+      searchables.add(iterator.next());
+    }
+
+    assertTrue(names(searchables).contains("ISSUE_6146_ALPHA"), 
names(searchables).toString());
+    assertTrue(names(searchables).contains("ISSUE_6146_PROD"), 
names(searchables).toString());
+    assertTrue(names(searchables).contains("ISSUE_6146_BETA"), 
names(searchables).toString());
+    assertEquals(1, 
names(searchables).stream().filter("ISSUE_6146_PROD"::equals).count());
+  }
+
+  @Test
+  void extensionPointAddsAllProjectsEvenWithoutAnActiveProject() throws 
Exception {
+    HopNamespace.setNamespace(null);
+    List<ISearchablesLocation> locations = new ArrayList<>();
+    new AddProjectsSearchablesLocationExtensionPoint()
+        .callExtensionPoint(LogChannel.GENERAL, new Variables(), locations);
+
+    assertEquals(1, locations.size());
+    assertInstanceOf(AllProjectsSearchablesLocation.class, locations.get(0));
+    assertFalse(locations.get(0).isIncludedInDefaultSearch());
+  }
+
+  @Test
+  void extensionPointKeepsTheActiveProjectAndAppendsAllProjects() throws 
Exception {
+    tempRoot = Files.createTempDirectory("hop-6146-active");
+    Path home = tempRoot.resolve("active");
+    Files.createDirectories(home);
+    registerProject("issue-6146-active", home);
+    HopNamespace.setNamespace("issue-6146-active");
+
+    List<ISearchablesLocation> locations = new ArrayList<>();
+    new AddProjectsSearchablesLocationExtensionPoint()
+        .callExtensionPoint(LogChannel.GENERAL, new Variables(), locations);
+
+    assertEquals(2, locations.size());
+    assertInstanceOf(ProjectsSearchablesLocation.class, locations.get(0));
+    assertEquals("Project issue-6146-active", 
locations.get(0).getLocationDescription());
+    assertInstanceOf(AllProjectsSearchablesLocation.class, locations.get(1));
+    assertTrue(
+        ((AllProjectsSearchablesLocation) locations.get(1))
+            .getAllowedProjectNames()
+            .contains("issue-6146-active"));
+  }
+
+  @Test
+  void accessControlFiltersTheAllProjectsList() throws Exception {
+    tempRoot = Files.createTempDirectory("hop-6146-access");
+    Path projectA = tempRoot.resolve("allowed");
+    Path projectB = tempRoot.resolve("denied");
+    Files.createDirectories(projectA);
+    Files.createDirectories(projectB);
+    writeVariables(projectA.resolve("env.json"), "ISSUE_6146_ALLOWED", "yes");
+    writeVariables(projectB.resolve("env.json"), "ISSUE_6146_DENIED", "no");
+    registerProject("issue-6146-allowed", projectA);
+    registerProject("issue-6146-denied", projectB);
+    registerEnvironment(
+        "issue-6146-allowed-env", "issue-6146-allowed", 
projectA.resolve("env.json"));
+    registerEnvironment("issue-6146-denied-env", "issue-6146-denied", 
projectB.resolve("env.json"));
+
+    ProjectsAccessConfig access = new ProjectsAccessConfig();
+    access.setEnabled(true);
+    access.setDefaultAllowAll(false);
+    access.setRules(
+        List.of(
+            new ProjectsAccessRule(
+                ProjectsAccessRule.TYPE_USER, "viewer", false, 
List.of("issue-6146-allowed"))));
+    setAccessCache(access);
+    HopSecurity.setProvider(() -> HopSecurityContext.forUser("viewer", 
Set.of(HopRole.READ_ONLY)));
+    try {
+      HopNamespace.setNamespace(null);
+      List<ISearchablesLocation> locations = new ArrayList<>();
+      new AddProjectsSearchablesLocationExtensionPoint()
+          .callExtensionPoint(LogChannel.GENERAL, new Variables(), locations);
+
+      AllProjectsSearchablesLocation all = null;
+      for (ISearchablesLocation location : locations) {
+        if (location instanceof AllProjectsSearchablesLocation candidate) {
+          all = candidate;
+        }
+      }
+      assertNotNull(all);
+      assertTrue(all.getAllowedProjectNames().contains("issue-6146-allowed"));
+      assertFalse(containsIgnoreCase(all.getAllowedProjectNames(), 
"issue-6146-denied"));
+
+      // The search thread may not see the session. A live check would allow 
every project again.
+      HopSecurity.reset();
+      ProjectsAccessConfig.clearCache();
+      assertTrue(
+          containsIgnoreCase(
+              AllProjectsSearchablesLocation.allowedProjectNames(), 
"issue-6146-denied"));
+
+      List<String> found = names(search(all, new Variables()));
+      assertTrue(found.contains("ISSUE_6146_ALLOWED"), found.toString());
+      assertFalse(found.contains("ISSUE_6146_DENIED"), found.toString());
+    } finally {
+      HopSecurity.reset();
+      ProjectsAccessConfig.clearCache();
+    }
+  }
+
+  @Test
+  void defaultSearchUsesOnlyTheActiveEnvironment() throws Exception {
+    tempRoot = Files.createTempDirectory("hop-6146-env");
+    Path home = tempRoot.resolve("home");
+    Files.createDirectories(home);
+    writeVariables(home.resolve("env-dev.json"), "ISSUE_6146_DEV", "dev");
+    writeVariables(home.resolve("env-prod.json"), "ISSUE_6146_PROD", "prod");
+    registerProject("issue-6146-env", home);
+    // dev is first. The active environment is prod, so a "first environment" 
search would be wrong.
+    registerEnvironment("issue-6146-dev", "issue-6146-env", 
"${PROJECT_HOME}/env-dev.json");
+    registerEnvironment("issue-6146-prod", "issue-6146-env", 
"${PROJECT_HOME}/env-prod.json");
+
+    IVariables variables = new Variables();
+    variables.setVariable(ProjectsUtil.VARIABLE_PROJECT_HOME, 
home.toAbsolutePath().toString());
+    variables.setVariable(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME, 
"issue-6146-prod");
+
+    ProjectConfig projectConfig =
+        
ProjectsConfigSingleton.getConfig().findProjectConfig("issue-6146-env");
+    List<ISearchable> active = search(new 
ProjectsSearchablesLocation(projectConfig), variables);
+    List<String> activeNames = names(active);
+    assertTrue(activeNames.contains("ISSUE_6146_PROD"), 
activeNames.toString());
+    assertFalse(activeNames.contains("ISSUE_6146_DEV"), 
activeNames.toString());
+    assertEquals(
+        home.resolve("env-prod.json").toAbsolutePath().toString(),
+        find(active, "ISSUE_6146_PROD").getFilename());
+
+    List<String> allNames = names(search(new AllProjectsSearchablesLocation(), 
new Variables()));
+    assertTrue(allNames.contains("ISSUE_6146_DEV"), allNames.toString());
+    assertTrue(allNames.contains("ISSUE_6146_PROD"), allNames.toString());
+  }
+
+  @Test
+  void variableSearchableKeyUsesTheResolvedConfigurationFile() throws 
Exception {
+    tempRoot = Files.createTempDirectory("hop-6146-resolved");
+    Path projectA = tempRoot.resolve("project-a");
+    Path projectB = tempRoot.resolve("project-b");
+    Files.createDirectories(projectA);
+    Files.createDirectories(projectB);
+    writeVariables(projectA.resolve("env-dev.json"), "ISSUE_6146_SHARED", 
"from-a");
+    writeVariables(projectB.resolve("env-dev.json"), "ISSUE_6146_SHARED", 
"from-b");
+    registerProject("issue-6146-a", projectA);
+    registerProject("issue-6146-b", projectB);
+    registerEnvironment("issue-6146-dev-a", "issue-6146-a", 
"${PROJECT_HOME}/env-dev.json");
+    registerEnvironment("issue-6146-dev-b", "issue-6146-b", 
"${PROJECT_HOME}/env-dev.json");
+
+    List<ISearchable> shared = new ArrayList<>();
+    for (ISearchable searchable : search(new AllProjectsSearchablesLocation(), 
new Variables())) {
+      if ("ISSUE_6146_SHARED".equals(searchable.getName())) {
+        shared.add(searchable);
+      }
+    }
+    assertEquals(2, shared.size(), names(shared).toString());
+    assertNotEquals(
+        HopGuiSearchHelper.searchableKey(shared.get(0)),
+        HopGuiSearchHelper.searchableKey(shared.get(1)));
+    List<String> filenames = new ArrayList<>();
+    for (ISearchable searchable : shared) {
+      filenames.add(searchable.getFilename());
+      assertFalse(searchable.getFilename().contains("${"));
+    }
+    assertTrue(
+        
filenames.contains(projectA.resolve("env-dev.json").toAbsolutePath().toString()),
+        filenames.toString());
+    assertTrue(
+        
filenames.contains(projectB.resolve("env-dev.json").toAbsolutePath().toString()),
+        filenames.toString());
+  }
+
+  private static List<String> names(List<ISearchable> searchables) {
+    List<String> names = new ArrayList<>();
+    for (ISearchable searchable : searchables) {
+      names.add(searchable.getName());
+    }
+    return names;
+  }
+
+  private static List<ISearchable> search(ISearchablesLocation location, 
IVariables variables)
+      throws Exception {
+    List<ISearchable> searchables = new ArrayList<>();
+    Iterator<ISearchable> iterator =
+        location.getSearchables(
+            HopMetadataUtil.getStandardHopMetadataProvider(variables), 
variables);
+    while (iterator.hasNext()) {
+      searchables.add(iterator.next());
+    }
+    return searchables;
+  }
+
+  private static ISearchable find(List<ISearchable> searchables, String name) {
+    for (ISearchable searchable : searchables) {
+      if (name.equals(searchable.getName())) {
+        return searchable;
+      }
+    }
+    return null;
+  }
+
+  private static boolean containsIgnoreCase(List<String> names, String value) {
+    for (String name : names) {
+      if (name != null && name.equalsIgnoreCase(value)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Install an access config without writing the developer's 
projects-access.json. */
+  private static void setAccessCache(ProjectsAccessConfig config) throws 
Exception {
+    Field field = ProjectsAccessConfig.class.getDeclaredField("cached");
+    field.setAccessible(true);
+    field.set(null, config);
+  }
+
+  private void registerProject(String name, Path home) {
+    ProjectsConfigSingleton.getConfig()
+        .addProjectConfig(
+            new ProjectConfig(name, home.toAbsolutePath().toString(), 
"project-config.json"));
+    projectNames.add(name);
+  }
+
+  private void registerEnvironment(String name, String projectName, Path 
configFile) {
+    registerEnvironment(name, projectName, 
configFile.toAbsolutePath().toString());
+  }
+
+  private void registerEnvironment(String name, String projectName, String 
configFile) {
+    LifecycleEnvironment environment =
+        new LifecycleEnvironment(name, "Testing", projectName, 
List.of(configFile));
+    ProjectsConfigSingleton.getConfig().addEnvironment(environment);
+    environmentNames.add(name);
+  }
+
+  private static void writeVariables(Path file, String name, String value) 
throws Exception {
+    DescribedVariablesConfigFile configFile =
+        new DescribedVariablesConfigFile(file.toAbsolutePath().toString());
+    configFile.setDescribedVariables(List.of(new DescribedVariable(name, 
value, "test")));
+    new ConfigFileSerializer()
+        .writeToFile(file.toAbsolutePath().toString(), 
configFile.getConfigMap());
+  }
+
+  private static List<ProjectConfig> safeProjects(ProjectsConfig config) {
+    if (config.getProjectConfigurations() == null) {
+      config.setProjectConfigurations(new ArrayList<>());
+    }
+    return config.getProjectConfigurations();
+  }
+
+  private static List<LifecycleEnvironment> safeEnvironments(ProjectsConfig 
config) {
+    if (config.getLifecycleEnvironments() == null) {
+      config.setLifecycleEnvironments(new ArrayList<>());
+    }
+    return config.getLifecycleEnvironments();
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/search/ProjectSearchableOpenTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/search/ProjectSearchableOpenTest.java
new file mode 100644
index 0000000000..b469c00986
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/search/ProjectSearchableOpenTest.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.search;
+
+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.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableCallback;
+import org.junit.jupiter.api.Test;
+
+class ProjectSearchableOpenTest {
+
+  @Test
+  void activeProjectOpensWithoutAQuestion() throws Exception {
+    List<String> opened = new ArrayList<>();
+    boolean didOpen =
+        ProjectSearchableOpen.prepare(
+            "project-a",
+            "Project-A",
+            () -> {
+              throw new AssertionError("same project must not ask to switch");
+            },
+            name -> {
+              throw new AssertionError("same project must not switch");
+            },
+            () -> opened.add("project-a"));
+
+    assertTrue(didOpen);
+    assertEquals(List.of("project-a"), opened);
+  }
+
+  @Test
+  void hitWithoutAProjectOpensInPlace() throws Exception {
+    List<String> opened = new ArrayList<>();
+    boolean didOpen =
+        ProjectSearchableOpen.prepare(
+            null,
+            "project-a",
+            () -> {
+              throw new AssertionError("global hit must not ask to switch");
+            },
+            name -> {
+              throw new AssertionError("global hit must not switch");
+            },
+            () -> opened.add("opened"));
+
+    assertTrue(didOpen);
+    assertEquals(List.of("opened"), opened);
+  }
+
+  @Test
+  void decliningDoesNotOpenInTheActiveProject() throws Exception {
+    AtomicBoolean switched = new AtomicBoolean();
+    List<String> opened = new ArrayList<>();
+
+    boolean didOpen =
+        ProjectSearchableOpen.prepare(
+            "project-b",
+            "project-a",
+            () -> false,
+            name -> {
+              switched.set(true);
+              return true;
+            },
+            () -> opened.add("opened"));
+
+    assertFalse(didOpen);
+    assertFalse(switched.get());
+    assertTrue(opened.isEmpty());
+  }
+
+  @Test
+  void failedSwitchDoesNotOpenInTheActiveProject() throws Exception {
+    List<String> opened = new ArrayList<>();
+
+    boolean didOpen =
+        ProjectSearchableOpen.prepare(
+            "project-b", "project-a", () -> true, name -> false, () -> 
opened.add("opened"));
+
+    assertFalse(didOpen);
+    assertTrue(opened.isEmpty());
+  }
+
+  @Test
+  void confirmedSwitchOpensOnlyAfterTheProjectChanges() throws Exception {
+    AtomicReference<String> current = new AtomicReference<>("project-a");
+    List<String> openedIn = new ArrayList<>();
+
+    boolean didOpen =
+        ProjectSearchableOpen.prepare(
+            "project-b",
+            current.get(),
+            () -> true,
+            name -> {
+              assertEquals("project-a", current.get());
+              current.set(name);
+              return true;
+            },
+            () -> openedIn.add(current.get()));
+
+    assertTrue(didOpen);
+    assertEquals("project-b", current.get());
+    assertEquals(List.of("project-b"), openedIn);
+  }
+
+  @Test
+  void openingAnotherProjectDoesNotRunAgainstTheActiveProject() throws 
Exception {
+    AtomicReference<String> current = new AtomicReference<>("project-a");
+    List<String> openedIn = new ArrayList<>();
+    ISearchable<String> delegate = delegate(openedIn, current);
+
+    ProjectSearchableOpen.Actions decline =
+        new ProjectSearchableOpen.Actions() {
+          @Override
+          public String activeProjectName() {
+            return current.get();
+          }
+
+          @Override
+          public boolean confirmSwitch(String projectName) {
+            return false;
+          }
+
+          @Override
+          public boolean switchToProject(String projectName) {
+            throw new AssertionError("declined switch must not change 
project");
+          }
+        };
+    ProjectScopedSearchable.forProject(delegate, "project-b", decline)
+        .getSearchCallback()
+        .callback(delegate, null);
+    assertTrue(openedIn.isEmpty());
+    assertEquals("project-a", current.get());
+
+    ProjectSearchableOpen.Actions accept =
+        new ProjectSearchableOpen.Actions() {
+          @Override
+          public String activeProjectName() {
+            return current.get();
+          }
+
+          @Override
+          public boolean confirmSwitch(String projectName) {
+            assertEquals("project-b", projectName);
+            assertEquals("project-a", current.get());
+            return true;
+          }
+
+          @Override
+          public boolean switchToProject(String projectName) {
+            assertEquals("project-a", current.get());
+            current.set(projectName);
+            return true;
+          }
+        };
+    ProjectScopedSearchable.forProject(delegate, "project-b", accept)
+        .getSearchCallback()
+        .callback(delegate, null);
+    assertEquals(List.of("project-b"), openedIn);
+    assertEquals("project-b", current.get());
+  }
+
+  private static ISearchable<String> delegate(
+      List<String> openedIn, AtomicReference<String> current) {
+    return new ISearchable<>() {
+      @Override
+      public String getLocation() {
+        return "Project project-b";
+      }
+
+      @Override
+      public String getName() {
+        return "daily";
+      }
+
+      @Override
+      public String getType() {
+        return "Pipeline";
+      }
+
+      @Override
+      public String getFilename() {
+        return "/b/daily.hpl";
+      }
+
+      @Override
+      public String getSearchableObject() {
+        return "daily";
+      }
+
+      @Override
+      public ISearchableCallback getSearchCallback() {
+        return (searchable, searchResult) -> openedIn.add(current.get());
+      }
+    };
+  }
+}
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 e6d660f1f2..7502b133aa 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
@@ -291,6 +291,69 @@ public final class HopGuiSearchHelper {
   /** Audit type under which the shared search-string history is stored (popup 
+ results panel). */
   public static final String AUDIT_TYPE_SEARCH_STRING = "search-string";
 
+  /**
+   * Combo index of the combined search: every location that {@link
+   * ISearchablesLocation#isIncludedInDefaultSearch()} reports.
+   */
+  public static final int ALL_LOADED_LOCATIONS_INDEX = 0;
+
+  /**
+   * Locations to search for a footer combo selection. Index {@link 
#ALL_LOADED_LOCATIONS_INDEX}
+   * keeps the default combined search. A higher index selects that single 
location (the combo lists
+   * the combined entry first, then each location).
+   */
+  public static List<ISearchablesLocation> selectLocations(
+      List<ISearchablesLocation> locations, int selectionIndex) {
+    if (locations == null || locations.isEmpty()) {
+      return List.of();
+    }
+    if (selectionIndex <= ALL_LOADED_LOCATIONS_INDEX) {
+      List<ISearchablesLocation> included = new ArrayList<>();
+      for (ISearchablesLocation location : locations) {
+        if (location != null && location.isIncludedInDefaultSearch()) {
+          included.add(location);
+        }
+      }
+      return included;
+    }
+    int locationIndex = selectionIndex - 1;
+    if (locationIndex >= locations.size()) {
+      return selectLocations(locations, ALL_LOADED_LOCATIONS_INDEX);
+    }
+    ISearchablesLocation location = locations.get(locationIndex);
+    return location == null ? List.of() : List.of(location);
+  }
+
+  /**
+   * Combo index of the location with this id, or {@link 
#ALL_LOADED_LOCATIONS_INDEX} when the id is
+   * empty or unknown.
+   */
+  public static int indexOfLocation(List<ISearchablesLocation> locations, 
String locationId) {
+    if (locations == null || locationId == null || locationId.isEmpty()) {
+      return ALL_LOADED_LOCATIONS_INDEX;
+    }
+    for (int i = 0; i < locations.size(); i++) {
+      ISearchablesLocation location = locations.get(i);
+      if (location != null && locationId.equals(location.getLocationId())) {
+        return i + 1;
+      }
+    }
+    return ALL_LOADED_LOCATIONS_INDEX;
+  }
+
+  /** Labels for the location combo: the combined entry first, then each 
location description. */
+  public static String[] locationLabels(
+      List<ISearchablesLocation> locations, String allLoadedLabel) {
+    List<ISearchablesLocation> safe = locations == null ? List.of() : 
locations;
+    String[] items = new String[safe.size() + 1];
+    items[0] = allLoadedLabel;
+    for (int i = 0; i < safe.size(); i++) {
+      ISearchablesLocation location = safe.get(i);
+      items[i + 1] = location == null ? "" : 
Const.NVL(location.getLocationDescription(), "");
+    }
+    return items;
+  }
+
   // --- Cross-location enumeration + grouping (shared by the popup and the 
perspective) -----------
 
   /** Internal section key for objects that are currently open in a Hop GUI 
tab. */
@@ -322,12 +385,15 @@ public final class HopGuiSearchHelper {
     Set<String> seen = new HashSet<>();
     for (int locationIndex = 0; locationIndex < locations.size(); 
locationIndex++) {
       ISearchablesLocation location = locations.get(locationIndex);
+      // Index 0 is reserved for the Hop GUI location so open tabs stay 
distinct when the list
+      // being searched does not start with that location.
+      int sourceIndex = sourceIndex(location, locationIndex);
       try {
         for (ISearchable searchable : enumerateSearchables(location, 
metadataProvider, variables)) {
           String key = searchableKey(searchable);
           if (seen.add(key)) {
             searchables.add(searchable);
-            sourceByKey.put(key, locationIndex);
+            sourceByKey.put(key, sourceIndex);
           }
         }
       } catch (Exception e) {
@@ -340,6 +406,14 @@ public final class HopGuiSearchHelper {
     return new EnumeratedSearchables(searchables, sourceByKey);
   }
 
+  /** Source index stored for a location. 0 means the objects came from the 
Hop GUI location. */
+  private static int sourceIndex(ISearchablesLocation location, int 
locationIndex) {
+    if (location instanceof HopGuiSearchLocation) {
+      return 0;
+    }
+    return locationIndex == 0 ? 1 : locationIndex;
+  }
+
   /**
    * 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
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
index f183081e3a..1d6d225b03 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
@@ -70,7 +70,7 @@ import org.eclipse.swt.widgets.TreeItem;
  *
  * <p>This used to be the body of the dedicated search perspective; it is now 
embeddable anywhere
  * (e.g. as a tab in the bottom dock) so a single results UI is shared by 
every "show all" entry
- * point.
+ * point. The footer shows the status and the search location.
  */
 public class HopGuiSearchResultsPanel extends Composite {
 
@@ -85,13 +85,18 @@ public class HopGuiSearchResultsPanel extends Composite {
 
   private final HopGui hopGui;
   private final String eventListenerId;
-  private List<ISearchablesLocation> searchablesLocations;
+
+  /** Location id to select when the panel opens, or null for all loaded 
locations. */
+  private final String initialLocationId;
+
+  private List<ISearchablesLocation> searchablesLocations = List.of();
 
   private Combo wSearchString;
   private Button wCaseSensitive;
   private Button wRegEx;
   private Button wSettings;
   private Label wlStatus;
+  private Combo wLocation;
   private SashForm sash;
   private Tree wTree;
   private Button wbOpen;
@@ -106,9 +111,12 @@ public class HopGuiSearchResultsPanel extends Composite {
   private Label wdValue;
   private Label wdDescription;
 
-  /** Searchables enumerated once across all locations and reused for every 
keystroke. */
+  /** Searchables enumerated once for a location and reused for every 
keystroke. */
   private HopGuiSearchHelper.EnumeratedSearchables cachedEnumeration;
 
+  /** Combo index the cache was built for. */
+  private int cachedLocationIndex = Integer.MIN_VALUE;
+
   private Map<Class<ISearchableAnalyser>, ISearchableAnalyser> cachedAnalysers;
 
   private final Runnable searchRunnable = () -> search(new Event());
@@ -128,9 +136,17 @@ public class HopGuiSearchResultsPanel extends Composite {
   private boolean suppressAutoSearch;
 
   public HopGuiSearchResultsPanel(Composite parent, HopGui hopGui) {
+    this(parent, hopGui, null);
+  }
+
+  /**
+   * @param initialLocationId location to select ({@link 
ISearchablesLocation#getLocationId()}), or
+   *     null for all loaded locations
+   */
+  public HopGuiSearchResultsPanel(Composite parent, HopGui hopGui, String 
initialLocationId) {
     super(parent, SWT.NONE);
     this.hopGui = hopGui;
-    this.searchablesLocations = hopGui.getSearchablesLocations();
+    this.initialLocationId = initialLocationId;
     this.eventListenerId = getClass().getName() + "-" + 
System.identityHashCode(this);
 
     buildUi();
@@ -169,7 +185,7 @@ public class HopGuiSearchResultsPanel extends Composite {
     //
     Composite toolbar = new Composite(this, SWT.NONE);
     PropsUi.setLook(toolbar);
-    toolbar.setLayout(new GridLayout(7, false));
+    toolbar.setLayout(new GridLayout(6, false));
     FormData fdToolbar = new FormData();
     fdToolbar.left = new FormAttachment(0, 0);
     fdToolbar.right = new FormAttachment(100, 0);
@@ -234,19 +250,35 @@ public class HopGuiSearchResultsPanel extends Composite {
           }
         });
 
-    wlStatus = new Label(toolbar, SWT.LEFT);
+    // --- Footer: status, search location at the bottom right, Open ---
+    //
+    Composite footer = new Composite(this, SWT.NONE);
+    PropsUi.setLook(footer);
+    GridLayout footerLayout = new GridLayout(3, false);
+    footerLayout.marginWidth = 0;
+    footerLayout.marginHeight = 0;
+    footer.setLayout(footerLayout);
+    FormData fdFooter = new FormData();
+    fdFooter.left = new FormAttachment(0, 0);
+    fdFooter.right = new FormAttachment(100, 0);
+    fdFooter.bottom = new FormAttachment(100, 0);
+    footer.setLayoutData(fdFooter);
+
+    wlStatus = new Label(footer, SWT.LEFT);
     PropsUi.setLook(wlStatus);
     wlStatus.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
 
-    // --- Bottom: Open button ---
-    //
-    wbOpen = new Button(this, SWT.PUSH);
+    wLocation = new Combo(footer, SWT.DROP_DOWN | SWT.READ_ONLY);
+    PropsUi.setLook(wLocation);
+    GridData gdLocation = new GridData(SWT.FILL, SWT.CENTER, false, false);
+    gdLocation.widthHint = (int) (280 * props.getZoomFactor());
+    wLocation.setLayoutData(gdLocation);
+    wLocation.addListener(SWT.Selection, e -> onLocationSelected());
+    refreshLocations();
+
+    wbOpen = new Button(footer, SWT.PUSH);
     PropsUi.setLook(wbOpen);
     wbOpen.setText(BaseMessages.getString(PKG, 
"HopGuiSearchResultsPanel.Open.Button.Label"));
-    FormData fdbOpen = new FormData();
-    fdbOpen.right = new FormAttachment(100, 0);
-    fdbOpen.bottom = new FormAttachment(100, 0);
-    wbOpen.setLayoutData(fdbOpen);
     wbOpen.addListener(SWT.Selection, this::open);
     wbOpen.setEnabled(false);
 
@@ -258,7 +290,7 @@ public class HopGuiSearchResultsPanel extends Composite {
     fdSash.left = new FormAttachment(0, 0);
     fdSash.right = new FormAttachment(100, 0);
     fdSash.top = new FormAttachment(toolbar, margin);
-    fdSash.bottom = new FormAttachment(wbOpen, -margin);
+    fdSash.bottom = new FormAttachment(footer, -margin);
     sash.setLayoutData(fdSash);
 
     wTree =
@@ -326,7 +358,7 @@ public class HopGuiSearchResultsPanel extends Composite {
 
   /** Refresh searchable locations + history and re-enumerate; call when this 
panel is (re)shown. */
   public void prepareForActivation() {
-    searchablesLocations = hopGui.getSearchablesLocations();
+    refreshLocations();
     refreshLastUsedSearchStrings();
     invalidateCache();
     focusSearchField();
@@ -432,6 +464,7 @@ public class HopGuiSearchResultsPanel extends Composite {
   }
 
   public void clearSearchFilters() {
+    refreshLocations();
     invalidateCache();
     if (wSearchString != null && !wSearchString.isDisposed()) {
       wSearchString.setText("");
@@ -447,31 +480,34 @@ public class HopGuiSearchResultsPanel extends Composite {
 
   private void invalidateCache() {
     cachedEnumeration = null;
+    cachedLocationIndex = Integer.MIN_VALUE;
   }
 
-  /** Make sure the searchables across all locations are enumerated once (and 
cached). */
-  private synchronized boolean ensureLoaded() {
-    if (cachedEnumeration != null) {
-      return true;
+  /**
+   * Searchables for the selected location, enumerated once and reused. Null 
when loading failed.
+   * The returned object stays valid if a later search replaces the cache.
+   */
+  private synchronized HopGuiSearchHelper.EnumeratedSearchables ensureLoaded(
+      List<ISearchablesLocation> locations, int locationIndex) {
+    if (cachedEnumeration != null && cachedLocationIndex == locationIndex) {
+      return cachedEnumeration;
     }
     try {
       if (cachedAnalysers == null) {
         cachedAnalysers = HopGuiSearchHelper.loadSearchableAnalysers();
       }
-      if (searchablesLocations == null) {
-        searchablesLocations = hopGui.getSearchablesLocations();
-      }
       cachedEnumeration =
           HopGuiSearchHelper.enumerateAll(
-              searchablesLocations,
+              HopGuiSearchHelper.selectLocations(locations, locationIndex),
               hopGui.getMetadataProvider(),
               hopGui.getVariables(),
               hopGui.getLog());
-      return true;
+      cachedLocationIndex = locationIndex;
+      return cachedEnumeration;
     } catch (Exception e) {
       hopGui.getLog().logError("Error loading searchables", e);
       invalidateCache();
-      return false;
+      return null;
     }
   }
 
@@ -495,12 +531,16 @@ public class HopGuiSearchResultsPanel extends Composite {
     final SearchLimits limits = SearchLimits.fromConfig();
     final int generation = searchGeneration.incrementAndGet();
     final Display display = getDisplay();
+    final int locationIndex = locationSelectionIndex();
+    final List<ISearchablesLocation> locations = searchablesLocations;
     setStatus(BaseMessages.getString(PKG, 
"HopGuiSearchResultsPanel.Status.Searching"));
 
     searchExecutor.execute(
         () -> {
           try {
-            if (!ensureLoaded()) {
+            HopGuiSearchHelper.EnumeratedSearchables enumerated =
+                ensureLoaded(locations, locationIndex);
+            if (enumerated == null) {
               display.asyncExec(
                   () -> {
                     if (generation == searchGeneration.get() && !isDisposed()) 
{
@@ -512,18 +552,18 @@ public class HopGuiSearchResultsPanel extends Composite {
             SearchQuery query = new SearchQuery(searchString, caseSensitive, 
regExp);
             SearchAnalysisResult analysis =
                 HopGuiSearchHelper.analyseRankedLimited(
-                    cachedEnumeration.getSearchables(),
+                    enumerated.getSearchables(),
                     query,
                     cachedAnalysers,
                     true,
                     limits,
-                    cachedEnumeration.getSourceByKey());
+                    enumerated.getSourceByKey());
             display.asyncExec(
                 () -> {
                   if (generation != searchGeneration.get() || isDisposed()) {
                     return;
                   }
-                  populateTree(analysis.getResults(), 
cachedEnumeration.getSourceByKey());
+                  populateTree(analysis.getResults(), 
enumerated.getSourceByKey());
                   setStatusFromAnalysis(analysis, limits);
                 });
           } catch (Exception e) {
@@ -538,6 +578,55 @@ public class HopGuiSearchResultsPanel extends Composite {
         });
   }
 
+  private void refreshLocations() {
+    String keep = selectedLocationId();
+    List<ISearchablesLocation> locations = hopGui.getSearchablesLocations();
+    searchablesLocations = locations == null ? List.of() : locations;
+    if (wLocation == null || wLocation.isDisposed()) {
+      return;
+    }
+    String allLoaded = BaseMessages.getString(PKG, "SearchLocation.AllLoaded");
+    wLocation.setItems(HopGuiSearchHelper.locationLabels(searchablesLocations, 
allLoaded));
+    wLocation.setToolTipText(BaseMessages.getString(PKG, 
"SearchLocation.Tooltip"));
+    int index = HopGuiSearchHelper.indexOfLocation(searchablesLocations, keep);
+    if (index >= wLocation.getItemCount()) {
+      index = HopGuiSearchHelper.ALL_LOADED_LOCATIONS_INDEX;
+    }
+    wLocation.select(index);
+  }
+
+  private void onLocationSelected() {
+    invalidateCache();
+    if (wSearchString != null
+        && !wSearchString.isDisposed()
+        && !Utils.isEmpty(wSearchString.getText())) {
+      search(new Event());
+    }
+  }
+
+  private int locationSelectionIndex() {
+    if (wLocation != null && !wLocation.isDisposed() && 
wLocation.getSelectionIndex() >= 0) {
+      return wLocation.getSelectionIndex();
+    }
+    return HopGuiSearchHelper.indexOfLocation(searchablesLocations, 
initialLocationId);
+  }
+
+  /**
+   * Id of the selected location, or null when the combined "all loaded 
locations" entry is
+   * selected. Before the combo exists, this is the id the panel was opened 
with.
+   */
+  private String selectedLocationId() {
+    if (wLocation != null && !wLocation.isDisposed() && 
wLocation.getSelectionIndex() >= 0) {
+      int index = wLocation.getSelectionIndex();
+      if (index <= 0 || index - 1 >= searchablesLocations.size()) {
+        return null;
+      }
+      ISearchablesLocation location = searchablesLocations.get(index - 1);
+      return location == null ? null : location.getLocationId();
+    }
+    return initialLocationId;
+  }
+
   private void setStatus(String text) {
     if (wlStatus != null && !wlStatus.isDisposed()) {
       wlStatus.setText(Const.NVL(text, ""));
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
index fe474e5359..afccd0cce9 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
@@ -29,6 +29,7 @@ import org.apache.hop.core.Const;
 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.ISearchablesLocation;
 import org.apache.hop.core.search.SearchQuery;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.i18n.BaseMessages;
@@ -66,10 +67,10 @@ import org.eclipse.swt.widgets.TreeItem;
 
 /**
  * IntelliJ-style "Search Everywhere" popup. It runs the same 
searchable-analyzer pipeline as the
- * {@link HopGuiSearchResultsPanel} (across all available locations) plus the 
global GUI commands,
- * and renders the results as a single grouped, keyboard-navigable list. 
Selecting a result opens it
- * through its regular {@link org.apache.hop.core.search.ISearchableCallback}. 
"Show all" hands the
- * query off to a Search tab in the bottom dock.
+ * {@link HopGuiSearchResultsPanel} plus the global GUI commands, and renders 
the results as a
+ * single grouped, keyboard-navigable list. The footer selects the search 
location. Selecting a
+ * result opens it through its regular {@link 
org.apache.hop.core.search.ISearchableCallback}. "Show
+ * all" hands the query off to a Search tab in the bottom dock.
  */
 public class SearchEverywhereDialog {
 
@@ -91,9 +92,17 @@ public class SearchEverywhereDialog {
   private final Shell parent;
   private final PropsUi props;
 
-  /** Searchables enumerated once when the popup opens and reused for every 
keystroke. */
+  /** Location id to select when the dialog opens, or null for all loaded 
locations. */
+  private final String initialLocationId;
+
+  private List<ISearchablesLocation> searchablesLocations = List.of();
+
+  /** Searchables enumerated once for a location and reused for every 
keystroke. */
   private List<ISearchable> cachedSearchables;
 
+  /** Combo index the cache was built for. */
+  private int cachedLocationIndex = Integer.MIN_VALUE;
+
   private Map<Class<ISearchableAnalyser>, ISearchableAnalyser> cachedAnalysers;
 
   /** For each cached searchable (by dedup key) the index of the location it 
came from (0 = GUI). */
@@ -104,6 +113,8 @@ public class SearchEverywhereDialog {
   private Button wCaseSensitive;
   private Button wRegEx;
   private Button wSettings;
+  private Composite wFooter;
+  private Combo wLocation;
   private Tree wTree;
   private TreeColumn nameColumn;
   private TreeColumn detailColumn;
@@ -124,9 +135,18 @@ public class SearchEverywhereDialog {
           });
 
   public SearchEverywhereDialog(Shell parent, HopGui hopGui) {
+    this(parent, hopGui, null);
+  }
+
+  /**
+   * @param initialLocationId location to select ({@link 
ISearchablesLocation#getLocationId()}), or
+   *     null for all loaded locations
+   */
+  public SearchEverywhereDialog(Shell parent, HopGui hopGui, String 
initialLocationId) {
     this.parent = parent;
     this.hopGui = hopGui;
     this.props = PropsUi.getInstance();
+    this.initialLocationId = initialLocationId;
   }
 
   public void open() {
@@ -191,16 +211,32 @@ public class SearchEverywhereDialog {
         BaseMessages.getString(PKG, 
"SearchEverywhereDialog.Settings.Tooltip"));
     wSettings.addListener(SWT.Selection, e -> openSettings());
 
-    // --- Footer: hint + "show all" handoff to the search perspective ---
-    wShowAll = new Link(shell, SWT.NONE);
+    // --- Footer: status on the left, search location at the bottom right ---
+    wFooter = new Composite(shell, SWT.NONE);
+    PropsUi.setLook(wFooter);
+    GridLayout footerLayout = new GridLayout(2, false);
+    footerLayout.marginWidth = 0;
+    footerLayout.marginHeight = 0;
+    wFooter.setLayout(footerLayout);
+    FormData fdFooter = new FormData();
+    fdFooter.left = new FormAttachment(0, 0);
+    fdFooter.right = new FormAttachment(100, 0);
+    fdFooter.bottom = new FormAttachment(100, 0);
+    wFooter.setLayoutData(fdFooter);
+
+    wShowAll = new Link(wFooter, SWT.NONE);
     PropsUi.setLook(wShowAll);
-    FormData fdShowAll = new FormData();
-    fdShowAll.left = new FormAttachment(0, 0);
-    fdShowAll.right = new FormAttachment(100, 0);
-    fdShowAll.bottom = new FormAttachment(100, 0);
-    wShowAll.setLayoutData(fdShowAll);
+    wShowAll.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
     wShowAll.addListener(SWT.Selection, e -> openShowAll());
 
+    wLocation = new Combo(wFooter, SWT.DROP_DOWN | SWT.READ_ONLY);
+    PropsUi.setLook(wLocation);
+    GridData gdLocation = new GridData(SWT.FILL, SWT.CENTER, false, false);
+    gdLocation.widthHint = (int) (280 * props.getZoomFactor());
+    wLocation.setLayoutData(gdLocation);
+    wLocation.addListener(SWT.Selection, e -> onLocationSelected());
+    populateLocations();
+
     // --- The grouped result tree (two user-resizable columns + horizontal 
scroll) ---
     wTree =
         new Tree(shell, SWT.SINGLE | SWT.BORDER | SWT.FULL_SELECTION | 
SWT.V_SCROLL | SWT.H_SCROLL);
@@ -216,7 +252,7 @@ public class SearchEverywhereDialog {
     fdTree.top = new FormAttachment(searchBar, margin);
     fdTree.left = new FormAttachment(0, 0);
     fdTree.right = new FormAttachment(100, 0);
-    fdTree.bottom = new FormAttachment(wShowAll, -margin);
+    fdTree.bottom = new FormAttachment(wFooter, -margin);
     wTree.setLayoutData(fdTree);
 
     // The Location column always fills the remaining width (also when the 
window or the Name column
@@ -281,6 +317,7 @@ public class SearchEverywhereDialog {
   private void invalidateCache() {
     cachedSearchables = null;
     cachedAnalysers = null;
+    cachedLocationIndex = Integer.MIN_VALUE;
     sourceByKey.clear();
   }
 
@@ -423,9 +460,11 @@ public class SearchEverywhereDialog {
    * de-duplicating objects that several locations report (e.g. metadata and 
variables are listed by
    * both the GUI and the project location). Subsequent keystrokes only 
re-match against this cache.
    */
-  private synchronized void ensureLoaded() {
-    if (cachedSearchables != null) {
-      return;
+  private synchronized HopGuiSearchHelper.EnumeratedSearchables ensureLoaded(
+      List<ISearchablesLocation> locations, int locationIndex) {
+    if (cachedSearchables != null && cachedLocationIndex == locationIndex) {
+      return new HopGuiSearchHelper.EnumeratedSearchables(
+          cachedSearchables, new HashMap<>(sourceByKey));
     }
     try {
       cachedAnalysers = HopGuiSearchHelper.loadSearchableAnalysers();
@@ -435,13 +474,15 @@ public class SearchEverywhereDialog {
     }
     HopGuiSearchHelper.EnumeratedSearchables enumerated =
         HopGuiSearchHelper.enumerateAll(
-            hopGui.getSearchablesLocations(),
+            HopGuiSearchHelper.selectLocations(locations, locationIndex),
             hopGui.getMetadataProvider(),
             hopGui.getVariables(),
             hopGui.getLog());
     cachedSearchables = enumerated.getSearchables();
+    cachedLocationIndex = locationIndex;
     sourceByKey.clear();
     sourceByKey.putAll(enumerated.getSourceByKey());
+    return enumerated;
   }
 
   private void runSearch() {
@@ -464,6 +505,8 @@ public class SearchEverywhereDialog {
     final SearchLimits limits = SearchLimits.fromConfig();
     final int generation = searchGeneration.incrementAndGet();
     final Display display = shell.getDisplay();
+    final int locationIndex = locationSelectionIndex();
+    final List<ISearchablesLocation> locations = searchablesLocations;
 
     wTree.removeAll();
     fullResultCount = 0;
@@ -472,17 +515,29 @@ public class SearchEverywhereDialog {
     searchExecutor.execute(
         () -> {
           try {
-            ensureLoaded();
+            HopGuiSearchHelper.EnumeratedSearchables enumerated =
+                ensureLoaded(locations, locationIndex);
             SearchQuery query = new SearchQuery(searchString, caseSensitive, 
regExp);
             SearchAnalysisResult analysis =
                 HopGuiSearchHelper.analyseRankedLimited(
-                    cachedSearchables, query, cachedAnalysers, true, limits, 
sourceByKey);
+                    enumerated.getSearchables(),
+                    query,
+                    cachedAnalysers,
+                    true,
+                    limits,
+                    enumerated.getSourceByKey());
             display.asyncExec(
                 () -> {
                   if (generation != searchGeneration.get() || shell == null || 
shell.isDisposed()) {
                     return;
                   }
-                  paintResults(analysis, searchString, regExp, caseSensitive, 
limits);
+                  paintResults(
+                      analysis,
+                      searchString,
+                      regExp,
+                      caseSensitive,
+                      limits,
+                      enumerated.getSourceByKey());
                 });
           } catch (Exception e) {
             hopGui.getLog().logError("Error while searching", e);
@@ -503,14 +558,15 @@ public class SearchEverywhereDialog {
       String searchString,
       boolean regExp,
       boolean caseSensitive,
-      SearchLimits limits) {
+      SearchLimits limits,
+      Map<String, Integer> sources) {
     lastAnalysis = analysis;
     wTree.removeAll();
     fullResultCount = 0;
 
     String searchTerm = regExp ? null : searchString;
     for (HopGuiSearchHelper.SearchSection section :
-        HopGuiSearchHelper.groupResults(analysis.getResults(), sourceByKey)) {
+        HopGuiSearchHelper.groupResults(analysis.getResults(), sources)) {
       addSection(section, searchTerm);
     }
     if (!analysis.isTruncated()) {
@@ -651,7 +707,7 @@ public class SearchEverywhereDialog {
 
   private void updateShowAllSearching() {
     wShowAll.setText(BaseMessages.getString(PKG, 
"SearchEverywhereDialog.Searching.Label"));
-    wShowAll.requestLayout();
+    layoutFooter();
   }
 
   private void updateShowAll() {
@@ -683,7 +739,54 @@ public class SearchEverywhereDialog {
     } else {
       wShowAll.setText(BaseMessages.getString(PKG, 
"SearchEverywhereDialog.Hint.Label"));
     }
-    wShowAll.requestLayout();
+    layoutFooter();
+  }
+
+  private void layoutFooter() {
+    if (wShowAll != null && !wShowAll.isDisposed()) {
+      wShowAll.requestLayout();
+    }
+    if (wFooter != null && !wFooter.isDisposed()) {
+      wFooter.requestLayout();
+    }
+  }
+
+  private void populateLocations() {
+    List<ISearchablesLocation> locations = hopGui.getSearchablesLocations();
+    searchablesLocations = locations == null ? List.of() : locations;
+    String allLoaded = BaseMessages.getString(PKG, "SearchLocation.AllLoaded");
+    wLocation.setItems(HopGuiSearchHelper.locationLabels(searchablesLocations, 
allLoaded));
+    wLocation.setToolTipText(BaseMessages.getString(PKG, 
"SearchLocation.Tooltip"));
+    int index = HopGuiSearchHelper.indexOfLocation(searchablesLocations, 
initialLocationId);
+    if (index >= wLocation.getItemCount()) {
+      index = HopGuiSearchHelper.ALL_LOADED_LOCATIONS_INDEX;
+    }
+    wLocation.select(index);
+  }
+
+  private void onLocationSelected() {
+    invalidateCache();
+    scheduleSearch();
+  }
+
+  private int locationSelectionIndex() {
+    if (wLocation != null && !wLocation.isDisposed() && 
wLocation.getSelectionIndex() >= 0) {
+      return wLocation.getSelectionIndex();
+    }
+    return HopGuiSearchHelper.indexOfLocation(searchablesLocations, 
initialLocationId);
+  }
+
+  /**
+   * Id of the selected location, or null when the combined "all loaded 
locations" entry is
+   * selected.
+   */
+  private String selectedLocationId() {
+    int index = locationSelectionIndex();
+    if (index <= 0 || index - 1 >= searchablesLocations.size()) {
+      return null;
+    }
+    ISearchablesLocation location = searchablesLocations.get(index - 1);
+    return location == null ? null : location.getLocationId();
   }
 
   private void openSelected() {
@@ -733,6 +836,7 @@ public class SearchEverywhereDialog {
     String searchString = wSearch.getText();
     boolean caseSensitive = wCaseSensitive.getSelection();
     boolean regExp = wRegEx.getSelection();
+    String locationId = selectedLocationId();
     persistHistory();
     dispose();
 
@@ -746,7 +850,7 @@ public class SearchEverywhereDialog {
             searchTabTitle(searchString),
             GuiResource.getInstance().getImageSearch(),
             true,
-            container -> new HopGuiSearchResultsPanel(container, hopGui));
+            container -> new HopGuiSearchResultsPanel(container, hopGui, 
locationId));
     if (content instanceof HopGuiSearchResultsPanel panel) {
       panel.setSearchQuery(searchString, caseSensitive, regExp);
       panel.focusSearchField();
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
index 3ab1efed16..6fd5eb28ba 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
@@ -16,6 +16,8 @@
 # limitations under the License.
 #
 
+SearchLocation.AllLoaded=All loaded locations
+SearchLocation.Tooltip=Search location. All loaded locations covers open files 
and the active project.
 SearchEverywhereDialog.Shell.Title=Search
 SearchEverywhereDialog.Search.Placeholder=Search pipelines, workflows, 
metadata, variables and actions...
 SearchEverywhereDialog.CaseSensitive.Label=Aa
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 39bc3be73a..9552504d52 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
@@ -17,6 +17,7 @@
 
 package org.apache.hop.ui.hopgui.search;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
@@ -25,13 +26,17 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 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.ISearchablesLocation;
 import org.apache.hop.core.search.SearchQuery;
 import org.apache.hop.core.search.SearchResult;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
 import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper.SearchObjectGroup;
@@ -307,6 +312,86 @@ class HopGuiSearchHelperTest {
     }
   }
 
+  @Test
+  void defaultSelectionSkipsLocationsThatOptOut() {
+    ISearchablesLocation gui = location("gui", "Current objects loaded in the 
Hop GUI", true);
+    ISearchablesLocation project = location("project:samples", "Project 
samples", true);
+    ISearchablesLocation allProjects = location("all-projects", "All 
projects", false);
+    List<ISearchablesLocation> locations = List.of(gui, project, allProjects);
+
+    assertEquals(List.of(gui, project), 
HopGuiSearchHelper.selectLocations(locations, 0));
+    assertEquals(List.of(allProjects), 
HopGuiSearchHelper.selectLocations(locations, 3));
+    assertEquals(List.of(gui, project), 
HopGuiSearchHelper.selectLocations(locations, 99));
+    assertEquals(3, HopGuiSearchHelper.indexOfLocation(locations, 
"all-projects"));
+    assertEquals(0, HopGuiSearchHelper.indexOfLocation(locations, null));
+    assertEquals(0, HopGuiSearchHelper.indexOfLocation(locations, "missing"));
+    assertArrayEquals(
+        new String[] {
+          "All loaded locations",
+          "Current objects loaded in the Hop GUI",
+          "Project samples",
+          "All projects"
+        },
+        HopGuiSearchHelper.locationLabels(locations, "All loaded locations"));
+  }
+
+  @Test
+  void searchingANonGuiLocationDoesNotMarkFilesAsOpen() throws Exception {
+    ISearchable openFile = pipeline("loader", "/p/loader.hpl");
+    ISearchable otherFile = pipeline("other", "/other/other.hpl");
+    HopGuiSearchLocation gui =
+        new HopGuiSearchLocation(null) {
+          @Override
+          public Iterator<ISearchable> getSearchables(
+              IHopMetadataProvider metadataProvider, IVariables variables) {
+            return List.of(openFile).iterator();
+          }
+        };
+    ISearchablesLocation other = location("all-projects", "All projects", 
false, otherFile);
+
+    HopGuiSearchHelper.EnumeratedSearchables onlyOther =
+        HopGuiSearchHelper.enumerateAll(List.of(other), null, null, null);
+    assertFalse(HopGuiSearchHelper.isOpenObject(otherFile, 
onlyOther.getSourceByKey()));
+
+    HopGuiSearchHelper.EnumeratedSearchables both =
+        HopGuiSearchHelper.enumerateAll(List.of(gui, other), null, null, null);
+    assertTrue(HopGuiSearchHelper.isOpenObject(openFile, 
both.getSourceByKey()));
+    assertFalse(HopGuiSearchHelper.isOpenObject(otherFile, 
both.getSourceByKey()));
+  }
+
+  private static ISearchablesLocation location(
+      String id, String description, boolean includedByDefault) {
+    return location(id, description, includedByDefault, null);
+  }
+
+  private static ISearchablesLocation location(
+      String id, String description, boolean includedByDefault, ISearchable 
searchable) {
+    return new ISearchablesLocation() {
+      @Override
+      public String getLocationDescription() {
+        return description;
+      }
+
+      @Override
+      public String getLocationId() {
+        return id;
+      }
+
+      @Override
+      public boolean isIncludedInDefaultSearch() {
+        return includedByDefault;
+      }
+
+      @Override
+      public Iterator<ISearchable> getSearchables(
+          IHopMetadataProvider metadataProvider, IVariables variables) {
+        return searchable == null
+            ? List.<ISearchable>of().iterator()
+            : List.of(searchable).iterator();
+      }
+    };
+  }
+
   /** Analyser map keys are searchable object classes (not analyser classes). 
*/
   @SuppressWarnings({"unchecked", "rawtypes"})
   private static Map<Class<ISearchableAnalyser>, ISearchableAnalyser> 
textFileAnalysers() {

Reply via email to