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() {