You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/06/30 08:49:15 UTC
(hop) branch main updated: convert search perspective to modal dialog, fixes #6125 (#7356)
This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 04e0b6e11e convert search perspective to modal dialog, fixes #6125 (#7356)
04e0b6e11e is described below
commit 04e0b6e11e6ae5ffa5ee613d937a731f2633e637
Author: Hans Van Akelyen <ha...@gmail.com>
AuthorDate: Tue Jun 30 10:49:10 2026 +0200
convert search perspective to modal dialog, fixes #6125 (#7356)
---
core/src/main/java/org/apache/hop/core/Const.java | 7 -
.../hop/core/gui/plugin/action/GuiAction.java | 74 +--
.../org/apache/hop/core/search/SearchMatcher.java | 208 +++++++
.../org/apache/hop/core/search/SearchQuery.java | 37 +-
.../apache/hop/core/search/SearchMatcherTest.java | 114 ++++
.../hop/core/search/BaseSearchableAnalyser.java | 46 +-
.../apache/hop/ui/core/dialog/ContextDialog.java | 26 +-
.../main/java/org/apache/hop/ui/hopgui/HopGui.java | 100 +--
.../configuration/ConfigurationPerspective.java | 25 +-
.../perspective/explorer/ExplorerPerspective.java | 29 +-
.../perspective/search/HopSearchPerspective.java | 519 ----------------
.../hop/ui/hopgui/search/HopGuiSearchHelper.java | 621 +++++++++++++++++++
.../ui/hopgui/search/HopGuiSearchResultsPanel.java | 574 ++++++++++++++++++
.../ui/hopgui/search/SearchEverywhereDialog.java | 672 +++++++++++++++++++++
...GuiTerminalPanel.java => HopGuiBottomDock.java} | 255 ++++++--
.../search/messages/messages_de_DE.properties | 36 --
.../search/messages/messages_en_US.properties | 35 --
.../search/messages/messages_es_AR.properties | 19 -
.../search/messages/messages_es_ES.properties | 35 --
.../search/messages/messages_fr_FR.properties | 32 -
.../search/messages/messages_it_IT.properties | 36 --
.../search/messages/messages_ja_JP.properties | 19 -
.../search/messages/messages_ko_KR.properties | 19 -
.../search/messages/messages_nl_NL.properties | 19 -
.../search/messages/messages_no_NO.properties | 19 -
.../search/messages/messages_pl_PL.properties | 19 -
.../search/messages/messages_pt_BR.properties | 36 --
.../search/messages/messages_pt_PT.properties | 19 -
.../search/messages/messages_zh_CN.properties | 36 --
.../search/messages/messages_en_US.properties | 54 ++
.../ui/hopgui/search/HopGuiSearchHelperTest.java | 194 ++++++
...nalPanelTest.java => HopGuiBottomDockTest.java} | 4 +-
32 files changed, 2800 insertions(+), 1138 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/Const.java b/core/src/main/java/org/apache/hop/core/Const.java
index 27d72a05b5..9ab20b8003 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -991,13 +991,6 @@ public class Const {
"Name of the variable to set so that Nulls are considered while parsing JSON files. If HOP_JSON_INPUT_INCLUDE_NULLS is \"Y\" then nulls will be included (default behavior) otherwise they will not be included")
public static final String HOP_JSON_INPUT_INCLUDE_NULLS = "HOP_JSON_INPUT_INCLUDE_NULLS";
- /** This variable is used to disable the strict searching of the context dialog */
- @Variable(
- value = "N",
- description =
- "This variable influences how the search is done in the context dialog, when set to Y it will do a strict search (Needed for automated UI testing)")
- public static final String HOP_CONTEXT_DIALOG_STRICT_SEARCH = "HOP_CONTEXT_DIALOG_STRICT_SEARCH";
-
/** By default, HOP do consider NULLs while parsing input */
public static final String JSON_INPUT_INCLUDE_NULLS = "Y";
diff --git a/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java b/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java
index 6830421308..8f533af3a9 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java
@@ -20,11 +20,8 @@ package org.apache.hop.core.gui.plugin.action;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
-import org.apache.commons.lang3.StringUtils;
-import org.apache.hop.core.Const;
import org.apache.hop.core.gui.plugin.IGuiActionLambda;
-import org.apache.hop.core.util.EnvUtil;
-import org.apache.hop.core.util.StringUtil;
+import org.apache.hop.core.search.SearchMatcher;
public class GuiAction {
private String id;
@@ -118,58 +115,29 @@ public class GuiAction {
this.classLoader = guiAction.getClassLoader();
}
- public boolean containsFilterStrings(String[] filters) {
- for (String filter : filters) {
- if (!containsFilterString(filter)) {
- return false;
- }
- }
- return true;
- }
-
- public boolean containsFilterString(String filter) {
- if (filter == null) {
- return false;
- }
-
- filter = StringUtil.removeDiacriticalMarks(filter);
-
- if (matchesString(name, filter)) {
- return true;
- }
- if (matchesString(tooltip, filter)) {
- return true;
- }
- if (type != null && matchesString(type.name(), filter)) {
- return true;
+ /**
+ * Score this action against a prepared matcher. Used to filter and rank actions (e.g. in the
+ * context dialog). Returns the best score across the action's name, tooltip, type, keywords and
+ * category; {@code 0} means no match.
+ *
+ * @param matcher the shared search matcher built from the user's filter text
+ * @return the best relevance score in {@code [0,1]}
+ */
+ public double matchScore(SearchMatcher matcher) {
+ // Weight the fields so a hit on the name ranks above a hit on a tooltip/type, which in turn
+ // ranks above a hit on a keyword or category. This keeps the most relevant actions (those whose
+ // name matches) at the top and stops loosely-related keyword/category hits from crowding in.
+ double best = matcher.score(name);
+ best = Math.max(best, 0.9 * matcher.score(tooltip));
+ if (type != null) {
+ best = Math.max(best, 0.9 * matcher.score(type.name()));
}
- for (String keyword : keywords) {
- if (matchesString(keyword, filter)) {
- return true;
+ if (keywords != null) {
+ for (String keyword : keywords) {
+ best = Math.max(best, 0.8 * matcher.score(keyword));
}
}
- if (matchesString(category, filter)) {
- return true;
- }
- return false;
- }
-
- private boolean matchesString(String string, String filter) {
- if (StringUtils.isEmpty(string)) {
- return false;
- }
-
- string = StringUtil.removeDiacriticalMarks(string);
-
- if (EnvUtil.getSystemProperty(Const.HOP_CONTEXT_DIALOG_STRICT_SEARCH, "N")
- .equalsIgnoreCase("Y")) {
- // Use strict matching of strings
- return string.equalsIgnoreCase(filter);
- } else {
- // TODO: consider some fuzzy matching algorithm
- // TODO: Do a Levenshtein distance on the filter string across all valid string indexes 0..
- return string.toUpperCase().contains(filter.toUpperCase());
- }
+ return Math.max(best, 0.7 * matcher.score(category));
}
@Override
diff --git a/core/src/main/java/org/apache/hop/core/search/SearchMatcher.java b/core/src/main/java/org/apache/hop/core/search/SearchMatcher.java
new file mode 100644
index 0000000000..d3c76a8fb9
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/search/SearchMatcher.java
@@ -0,0 +1,208 @@
+/*
+ * 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.core.search;
+
+import java.util.Locale;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.util.HopJaroWinklerDistance;
+import org.apache.hop.core.util.StringUtil;
+
+/**
+ * A single, reusable string matcher shared by every search box in Hop (global search, file explorer
+ * filter, settings filter, the transform/action picker, ...). It is built once from a query and
+ * then scores any number of candidate strings.
+ *
+ * <p>Matching is, in order of decreasing score:
+ *
+ * <ol>
+ * <li>exact (the whole normalized target equals the term),
+ * <li>word-prefix (a word in the target starts with the term),
+ * <li>substring (the term appears somewhere in the target),
+ * <li>fuzzy (Jaro-Winkler similarity above a threshold) - only when fuzzy matching is enabled.
+ * </ol>
+ *
+ * <p>The query is normalized (diacritics removed, optionally lower-cased, whitespace collapsed) and
+ * split into terms on whitespace and commas; <b>all</b> terms must match (AND). Alternatively a
+ * regular-expression mode does a partial regex match.
+ *
+ * <p>{@link #score(String)} returns a value in {@code [0,1]} ({@code 0} means "no match") which
+ * callers can use to rank results; {@link #matches(String)} is the boolean shortcut.
+ */
+public class SearchMatcher {
+
+ /** Default Jaro-Winkler similarity above which a fuzzy match is accepted. */
+ public static final double DEFAULT_FUZZY_THRESHOLD = 0.86;
+
+ /** Fuzzy matching is only attempted for terms at least this long (avoids noise on tiny terms). */
+ private static final int MIN_FUZZY_TERM_LENGTH = 3;
+
+ /**
+ * A fuzzy match is only considered when the shorter of the two strings is at least this fraction
+ * of the longer one. Without this a long query (e.g. "tableinput") would fuzzily latch onto a
+ * much shorter candidate (e.g. the keyword "table") thanks to the Jaro-Winkler common-prefix
+ * bonus, which is too lenient: a genuine typo is roughly the same length as its target.
+ */
+ private static final double MIN_FUZZY_LENGTH_RATIO = 0.6;
+
+ private static final double SCORE_EXACT = 1.0;
+ private static final double SCORE_WORD = 0.97;
+ private static final double SCORE_PREFIX = 0.92;
+ private static final double SCORE_SUBSTRING = 0.8;
+ private static final double FUZZY_WEIGHT = 0.7;
+
+ private final boolean caseSensitive;
+ private final boolean regex;
+ private final boolean fuzzy;
+ private final double fuzzyThreshold;
+ private final String[] terms;
+ private final Pattern pattern;
+
+ public SearchMatcher(String query, boolean caseSensitive, boolean regEx, boolean fuzzy) {
+ this(query, caseSensitive, regEx, fuzzy, DEFAULT_FUZZY_THRESHOLD);
+ }
+
+ public SearchMatcher(
+ String query, boolean caseSensitive, boolean regEx, boolean fuzzy, double fuzzyThreshold) {
+ this.caseSensitive = caseSensitive;
+ this.regex = regEx;
+ this.fuzzy = fuzzy && !regEx;
+ this.fuzzyThreshold = fuzzyThreshold;
+
+ if (regEx) {
+ Pattern compiled = null;
+ if (StringUtils.isNotEmpty(query)) {
+ try {
+ compiled = Pattern.compile(query, caseSensitive ? 0 : Pattern.CASE_INSENSITIVE);
+ } catch (Exception e) {
+ // An incomplete/invalid expression simply matches nothing instead of throwing.
+ compiled = null;
+ }
+ }
+ this.pattern = compiled;
+ this.terms = new String[0];
+ } else {
+ this.pattern = null;
+ String normalized = normalize(query, caseSensitive);
+ this.terms = normalized.isEmpty() ? new String[0] : normalized.split(" ");
+ }
+ }
+
+ /**
+ * Normalize a string for matching: remove diacritical marks, optionally lower-case, turn commas
+ * into spaces and collapse runs of whitespace.
+ */
+ public static String normalize(String string, boolean caseSensitive) {
+ if (StringUtils.isEmpty(string)) {
+ return "";
+ }
+ String normalized = StringUtil.removeDiacriticalMarks(string);
+ if (!caseSensitive) {
+ normalized = normalized.toLowerCase(Locale.ROOT);
+ }
+ return normalized.replace(',', ' ').replaceAll("\\s+", " ").trim();
+ }
+
+ /** Whether the given target matches at all. */
+ public boolean matches(String target) {
+ return score(target) > 0.0;
+ }
+
+ /**
+ * Score a candidate string against the query.
+ *
+ * @param target the candidate
+ * @return a relevance score in {@code [0,1]}; {@code 0} means no match
+ */
+ public double score(String target) {
+ // Regular expression mode: a partial match counts. An invalid/empty pattern matches nothing.
+ if (regex) {
+ return pattern != null && target != null && pattern.matcher(target).find() ? 1.0 : 0.0;
+ }
+
+ // An empty query matches everything non-empty (keeps the old "match all" behavior).
+ if (terms.length == 0) {
+ return StringUtils.isNotEmpty(target) ? 1.0 : 0.0;
+ }
+ if (StringUtils.isEmpty(target)) {
+ return 0.0;
+ }
+
+ String normalizedTarget = normalize(target, caseSensitive);
+ String[] words = normalizedTarget.split(" ");
+
+ double total = 0.0;
+ for (String term : terms) {
+ double termScore = scoreTerm(term, normalizedTarget, words);
+ if (termScore <= 0.0) {
+ // AND semantics: every term must match.
+ return 0.0;
+ }
+ total += termScore;
+ }
+ return total / terms.length;
+ }
+
+ private double scoreTerm(String term, String target, String[] words) {
+ if (target.equals(term)) {
+ return SCORE_EXACT;
+ }
+ if (target.contains(term)) {
+ for (String word : words) {
+ if (word.equals(term)) {
+ return SCORE_WORD;
+ }
+ }
+ for (String word : words) {
+ if (word.startsWith(term)) {
+ return SCORE_PREFIX;
+ }
+ }
+ return SCORE_SUBSTRING;
+ }
+ if (fuzzy && term.length() >= MIN_FUZZY_TERM_LENGTH) {
+ double best = similarity(term, target);
+ for (String word : words) {
+ best = Math.max(best, similarity(term, word));
+ }
+ if (best >= fuzzyThreshold) {
+ return best * FUZZY_WEIGHT;
+ }
+ }
+ return 0.0;
+ }
+
+ /**
+ * Jaro-Winkler similarity, but {@code 0} when the two strings differ too much in length to be a
+ * plausible typo of one another (see {@link #MIN_FUZZY_LENGTH_RATIO}).
+ */
+ private static double similarity(String a, String b) {
+ if (StringUtils.isEmpty(a) || StringUtils.isEmpty(b)) {
+ return 0.0;
+ }
+ int shorter = Math.min(a.length(), b.length());
+ int longer = Math.max(a.length(), b.length());
+ if ((double) shorter / longer < MIN_FUZZY_LENGTH_RATIO) {
+ return 0.0;
+ }
+ HopJaroWinklerDistance distance = new HopJaroWinklerDistance();
+ distance.apply(a, b);
+ Double jw = distance.getJaroWinklerDistance();
+ return jw == null ? 0.0 : jw;
+ }
+}
diff --git a/core/src/main/java/org/apache/hop/core/search/SearchQuery.java b/core/src/main/java/org/apache/hop/core/search/SearchQuery.java
index b547b05ab5..0c1a9a0a0a 100644
--- a/core/src/main/java/org/apache/hop/core/search/SearchQuery.java
+++ b/core/src/main/java/org/apache/hop/core/search/SearchQuery.java
@@ -17,13 +17,14 @@
package org.apache.hop.core.search;
-import org.apache.commons.lang3.StringUtils;
-
public class SearchQuery implements ISearchQuery {
private String searchString;
private boolean caseSensitive;
private boolean regEx;
+ /** Lazily built matcher, shared by all search boxes. Rebuilt when the query changes. */
+ private SearchMatcher matcher;
+
public SearchQuery() {}
public SearchQuery(String searchString, boolean caseSensitive, boolean regEx) {
@@ -32,27 +33,18 @@ public class SearchQuery implements ISearchQuery {
this.regEx = regEx;
}
- @Override
- public boolean matches(String string) {
- if (StringUtils.isEmpty(searchString)) {
- // match everything non-null
- //
- return StringUtils.isNotEmpty(string);
+ private SearchMatcher matcher() {
+ if (matcher == null) {
+ // Content matching: normalized + multi-term substring (or regex). No fuzzy matching here -
+ // fuzzy is only meaningful for short names, not for long descriptions / settings values.
+ matcher = new SearchMatcher(searchString, caseSensitive, regEx, false);
}
+ return matcher;
+ }
- if (regEx) {
- if (caseSensitive) {
- return string.matches(searchString);
- } else {
- return string.toLowerCase().matches(searchString.toLowerCase());
- }
- } else {
- if (caseSensitive) {
- return string.contains(searchString);
- } else {
- return string.toLowerCase().contains(searchString.toLowerCase());
- }
- }
+ @Override
+ public boolean matches(String string) {
+ return matcher().matches(string);
}
/**
@@ -70,6 +62,7 @@ public class SearchQuery implements ISearchQuery {
*/
public void setSearchString(String searchString) {
this.searchString = searchString;
+ this.matcher = null;
}
/**
@@ -87,6 +80,7 @@ public class SearchQuery implements ISearchQuery {
*/
public void setCaseSensitive(boolean caseSensitive) {
this.caseSensitive = caseSensitive;
+ this.matcher = null;
}
/**
@@ -104,5 +98,6 @@ public class SearchQuery implements ISearchQuery {
*/
public void setRegEx(boolean regEx) {
this.regEx = regEx;
+ this.matcher = null;
}
}
diff --git a/core/src/test/java/org/apache/hop/core/search/SearchMatcherTest.java b/core/src/test/java/org/apache/hop/core/search/SearchMatcherTest.java
new file mode 100644
index 0000000000..a12e5abf57
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/search/SearchMatcherTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.core.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 org.junit.jupiter.api.Test;
+
+class SearchMatcherTest {
+
+ private static SearchMatcher substring(String q) {
+ return new SearchMatcher(q, false, false, false);
+ }
+
+ private static SearchMatcher fuzzy(String q) {
+ return new SearchMatcher(q, false, false, true);
+ }
+
+ @Test
+ void caseInsensitiveSubstring() {
+ assertTrue(substring("load").matches("Load Customers"));
+ assertTrue(substring("CUSTOMERS").matches("load customers"));
+ assertFalse(substring("orders").matches("load customers"));
+ }
+
+ @Test
+ void caseSensitiveRespectsCase() {
+ SearchMatcher matcher = new SearchMatcher("Load", true, false, false);
+ assertTrue(matcher.matches("Load Customers"));
+ assertFalse(matcher.matches("load customers"));
+ }
+
+ @Test
+ void multiTermIsAnd() {
+ assertTrue(substring("load customer").matches("customer loader pipeline"));
+ assertTrue(substring("load,customer").matches("customer loader pipeline"));
+ assertFalse(substring("load order").matches("customer loader pipeline"));
+ }
+
+ @Test
+ void emptyQueryMatchesNonEmpty() {
+ assertTrue(substring("").matches("anything"));
+ assertFalse(substring("").matches(""));
+ }
+
+ @Test
+ void diacriticsAreIgnored() {
+ assertTrue(substring("recuperer").matches("Récupérer les données"));
+ assertTrue(substring("Récupérer").matches("recuperer les donnees"));
+ }
+
+ @Test
+ void scoreRanksExactAboveSubstring() {
+ SearchMatcher matcher = substring("customer");
+ double exact = matcher.score("customer");
+ double word = matcher.score("the customer table");
+ double substring = matcher.score("customers");
+ assertTrue(exact >= word, "exact should outrank a word match");
+ assertTrue(word >= substring, "word match should outrank a mid-word substring");
+ assertEquals(0.0, matcher.score("orders"));
+ }
+
+ @Test
+ void fuzzyMatchesTyposOnlyWhenEnabled() {
+ // "custmer" is a typo of "customer"
+ assertFalse(substring("custmer").matches("customer pipeline"));
+ assertTrue(fuzzy("custmer").matches("customer pipeline"));
+ // A fuzzy match scores below a real substring match.
+ assertTrue(fuzzy("custmer").score("customer") < fuzzy("customer").score("customer"));
+ }
+
+ @Test
+ void fuzzyDoesNotMatchUnrelated() {
+ assertFalse(fuzzy("customer").matches("orders warehouse"));
+ }
+
+ @Test
+ void fuzzyRequiresSimilarLength() {
+ // "tableinput" is just "table input" without the space -> should fuzzy match.
+ assertTrue(fuzzy("tableinput").matches("Table input"));
+ // ...but it must NOT latch onto the much shorter keyword "table" (the old leniency bug).
+ assertFalse(fuzzy("tableinput").matches("table"));
+ }
+
+ @Test
+ void regexIsPartialMatch() {
+ SearchMatcher matcher = new SearchMatcher("cust.*er", false, true, false);
+ assertTrue(matcher.matches("the customer table"));
+ assertFalse(matcher.matches("the orders table"));
+ }
+
+ @Test
+ void invalidRegexMatchesNothing() {
+ SearchMatcher matcher = new SearchMatcher("cust(er", false, true, false);
+ assertFalse(matcher.matches("customer"));
+ }
+}
diff --git a/engine/src/main/java/org/apache/hop/core/search/BaseSearchableAnalyser.java b/engine/src/main/java/org/apache/hop/core/search/BaseSearchableAnalyser.java
index 64a870443a..4ec869800e 100644
--- a/engine/src/main/java/org/apache/hop/core/search/BaseSearchableAnalyser.java
+++ b/engine/src/main/java/org/apache/hop/core/search/BaseSearchableAnalyser.java
@@ -25,6 +25,16 @@ import org.apache.hop.core.reflection.StringSearcher;
public abstract class BaseSearchableAnalyser<T> {
+ /**
+ * Match a single property of a searchable. Only the property <em>value</em> (the actual content)
+ * is matched - not the internal property name/label - so the search behaves like a content search
+ * instead of matching field labels. When it matches, the property name is recorded as the result
+ * description so the UI can show <em>where</em> the hit was.
+ *
+ * @param propertyName a human-readable label for the property (e.g. "pipeline description"); used
+ * only to describe the match, it is not matched against
+ * @param propertyValue the actual value that is matched against
+ */
protected void matchProperty(
ISearchable<T> parent,
List<ISearchResult> searchResults,
@@ -32,26 +42,17 @@ public abstract class BaseSearchableAnalyser<T> {
String propertyName,
String propertyValue,
String component) {
- if (searchQuery.matches(propertyName)) {
- searchResults.add(
- new SearchResult(
- parent,
- propertyName,
- "matching property name: " + propertyName,
- component,
- propertyValue));
- }
if (StringUtils.isNotEmpty(propertyValue) && searchQuery.matches(propertyValue)) {
searchResults.add(
- new SearchResult(
- parent,
- propertyValue,
- "matching property value: " + propertyValue,
- component,
- propertyValue));
+ new SearchResult(parent, propertyValue, propertyName, component, propertyValue));
}
}
+ /**
+ * Reflectively match the string fields of an object (e.g. a transform's or action's settings).
+ * Only field <em>values</em> are matched, not the Java field names. The field name is recorded in
+ * the description so the UI can show which setting matched.
+ */
protected void matchObjectFields(
ISearchable<T> searchable,
List<ISearchResult> searchResults,
@@ -63,24 +64,15 @@ public abstract class BaseSearchableAnalyser<T> {
StringSearcher.findMetaData(
object, 1, stringSearchResults, searchable.getSearchableObject(), searchable);
for (StringSearchResult stringSearchResult : stringSearchResults) {
- if (searchQuery.matches(stringSearchResult.getFieldName())) {
- searchResults.add(
- new SearchResult(
- searchable,
- stringSearchResult.getFieldName(),
- descriptionPrefix + " : " + stringSearchResult.getFieldName(),
- component,
- stringSearchResult.getString()));
- }
String resultString = stringSearchResult.getString();
- if (resultString != null && searchQuery.matches(resultString)) {
+ if (StringUtils.isNotEmpty(resultString) && searchQuery.matches(resultString)) {
searchResults.add(
new SearchResult(
searchable,
- stringSearchResult.getString(),
+ resultString,
descriptionPrefix + " : " + stringSearchResult.getFieldName(),
component,
- stringSearchResult.getString()));
+ resultString));
}
}
}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index a76c59a63d..7dcac7a119 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -20,6 +20,7 @@ package org.apache.hop.ui.core.dialog;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -36,6 +37,7 @@ import org.apache.hop.core.gui.plugin.action.GuiAction;
import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementType;
import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.search.SearchMatcher;
import org.apache.hop.history.AuditManager;
import org.apache.hop.history.AuditState;
import org.apache.hop.i18n.BaseMessages;
@@ -1116,18 +1118,22 @@ public class ContextDialog extends Dialog {
text = "";
}
- String[] filters = text.split(",");
- for (int i = 0; i < filters.length; i++) {
- filters[i] = Const.trim(filters[i]);
- }
-
filteredItems.clear();
- for (Item item : items) {
- GuiAction action = item.getAction();
-
- if (StringUtils.isEmpty(text) || action.containsFilterStrings(filters)) {
- filteredItems.add(item);
+ if (StringUtils.isEmpty(text)) {
+ filteredItems.addAll(items);
+ } else {
+ // Score every action with the shared matcher (fuzzy + multi-term), keep the matches and sort
+ // them best-first.
+ SearchMatcher matcher = new SearchMatcher(text, false, false, true);
+ Map<Item, Double> scores = new IdentityHashMap<>();
+ for (Item item : items) {
+ double score = item.getAction().matchScore(matcher);
+ if (score > 0.0) {
+ scores.put(item, score);
+ filteredItems.add(item);
+ }
}
+ filteredItems.sort((a, b) -> Double.compare(scores.get(b), scores.get(a)));
}
if (filteredItems.isEmpty()) {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index d80202514f..94a264a450 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -122,8 +122,8 @@ import org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspecti
import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
-import org.apache.hop.ui.hopgui.perspective.search.HopSearchPerspective;
import org.apache.hop.ui.hopgui.search.HopGuiSearchLocation;
+import org.apache.hop.ui.hopgui.search.SearchEverywhereDialog;
import org.apache.hop.ui.hopgui.welcome.WelcomeDialog;
import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
import org.apache.hop.ui.util.EnvironmentUtils;
@@ -284,9 +284,9 @@ public class HopGui
private Composite mainPerspectivesComposite;
private HopPerspectiveManager perspectiveManager;
private IHopPerspective activePerspective;
- private org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel terminalPanel;
+ private org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock terminalPanel;
- public org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel getTerminalPanel() {
+ public org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock getTerminalPanel() {
return terminalPanel;
}
@@ -1280,10 +1280,7 @@ public class HopGui
@GuiKeyboardShortcut(key = 'f', control = true)
@GuiOsxKeyboardShortcut(key = 'f', command = true)
public void menuEditFind() {
- IHopPerspective perspective = perspectiveManager.findPerspective(HopSearchPerspective.class);
- if (perspective != null) {
- perspective.activate();
- }
+ new SearchEverywhereDialog(getActiveShell(), this).open();
}
@GuiMenuElement(
@@ -1778,48 +1775,47 @@ public class HopGui
* Add a main composite where the various perspectives can parent on to show stuff... Its area is
* to just below the main toolbar and to the right of the perspectives toolbar.
*
- * <p>Wraps everything in a HopGuiTerminalPanel which provides the terminal panel at the bottom,
- * with perspectives rendering in the top section.
+ * <p>Wraps everything in a {@link org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock} which hosts
+ * the perspectives in its top section and a tabbed dock (terminals and other tools such as the
+ * search results) in its bottom section. The integrated terminal is a gated capability: it is
+ * turned off on the web (no AWT/PTY there) and can be disabled in {@code
+ * disabledGuiElements.xml}.
*/
private void addMainPerspectivesComposite() {
- if (EnvironmentUtils.getInstance().isWeb()) {
- mainPerspectivesComposite = new Composite(mainHopGuiComposite, SWT.NONE);
- FormData fdPerspectives = new FormData();
- fdPerspectives.top = new FormAttachment(0, 0);
- fdPerspectives.left = new FormAttachment(perspectivesSidebar, 0);
- fdPerspectives.bottom = new FormAttachment(100, 0);
- fdPerspectives.right = new FormAttachment(100, 0);
- mainPerspectivesComposite.setLayoutData(fdPerspectives);
- mainPerspectivesComposite.setLayout(new StackLayout());
- } else {
- terminalPanel =
- new org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel(mainHopGuiComposite, this);
- FormData fdTerminalPanel = new FormData();
- fdTerminalPanel.top = new FormAttachment(0, 0);
- fdTerminalPanel.left = new FormAttachment(perspectivesSidebar, 0);
- fdTerminalPanel.bottom = new FormAttachment(100, 0);
- fdTerminalPanel.right = new FormAttachment(100, 0);
- terminalPanel.setLayoutData(fdTerminalPanel);
-
- // Register so Tools > Terminal menu items can invoke panel methods
- String menuInstanceId = mainMenuWidgets.getInstanceId();
- org.apache.hop.core.gui.plugin.GuiRegistry.getInstance()
- .registerGuiPluginObject(
- getId(),
- org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel.class.getName(),
- menuInstanceId,
- terminalPanel);
- terminalPanel.addDisposeListener(
- e ->
- org.apache.hop.core.gui.plugin.GuiRegistry.getInstance()
- .removeGuiPluginObject(
- getId(),
- org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel.class.getName(),
- menuInstanceId));
-
- mainPerspectivesComposite = terminalPanel.getPerspectiveComposite();
- mainPerspectivesComposite.setLayout(new StackLayout());
- }
+ boolean terminalsEnabled =
+ !EnvironmentUtils.getInstance().isWeb()
+ && !org.apache.hop.core.gui.plugin.GuiRegistry.getDisabledGuiElements()
+ .contains(
+ org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock.ID_MAIN_MENU_TOOLS_TERMINAL);
+
+ terminalPanel =
+ new org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock(
+ mainHopGuiComposite, this, terminalsEnabled);
+ FormData fdTerminalPanel = new FormData();
+ fdTerminalPanel.top = new FormAttachment(0, 0);
+ fdTerminalPanel.left = new FormAttachment(perspectivesSidebar, 0);
+ fdTerminalPanel.bottom = new FormAttachment(100, 0);
+ fdTerminalPanel.right = new FormAttachment(100, 0);
+ terminalPanel.setLayoutData(fdTerminalPanel);
+
+ // Register so Tools > Terminal menu items can invoke dock methods
+ String menuInstanceId = mainMenuWidgets.getInstanceId();
+ org.apache.hop.core.gui.plugin.GuiRegistry.getInstance()
+ .registerGuiPluginObject(
+ getId(),
+ org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock.class.getName(),
+ menuInstanceId,
+ terminalPanel);
+ terminalPanel.addDisposeListener(
+ e ->
+ org.apache.hop.core.gui.plugin.GuiRegistry.getInstance()
+ .removeGuiPluginObject(
+ getId(),
+ org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock.class.getName(),
+ menuInstanceId));
+
+ mainPerspectivesComposite = terminalPanel.getPerspectiveComposite();
+ mainPerspectivesComposite.setLayout(new StackLayout());
}
public void setUndoMenu(IUndo undoInterface) {
@@ -1991,6 +1987,16 @@ public class HopGui
}
}
+ /**
+ * Gets the widgets of the main menu bar. Used by the global search popup to look up and fire menu
+ * commands.
+ *
+ * @return the main menu widgets
+ */
+ public GuiMenuWidgets getMainMenuWidgets() {
+ return mainMenuWidgets;
+ }
+
/**
* Gets metadataProvider
*
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
index 7ec1484189..d37028ea62 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
@@ -31,6 +31,7 @@ import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
import org.apache.hop.core.gui.plugin.tab.GuiTabItem;
import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.search.SearchMatcher;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.ui.core.FormDataBuilder;
import org.apache.hop.ui.core.PropsUi;
@@ -107,6 +108,7 @@ public class ConfigurationPerspective implements IHopPerspective {
private List<Object> tabInstances = new ArrayList<>(); // Store tab instances for refreshing
private List<Control> highlightedControls = new ArrayList<>();
private String currentSearchText = ""; // Track current search for re-applying highlights
+ private SearchMatcher searchMatcher = new SearchMatcher("", false, false, true);
private Color highlightColor; // Custom neutral highlight color
private Text searchBox;
@Getter private static ConfigurationPerspective instance;
@@ -431,6 +433,7 @@ public class ConfigurationPerspective implements IHopPerspective {
if (currentSearchText.length() < 3) {
currentSearchText = "";
}
+ searchMatcher = new SearchMatcher(currentSearchText, false, false, true);
// Clear previous highlights
clearHighlights();
@@ -453,8 +456,7 @@ public class ConfigurationPerspective implements IHopPerspective {
// Search through all categories and their content
for (TreeItem item : categoryTree.getItems()) {
- String categoryName = item.getText().toLowerCase();
- boolean categoryMatches = categoryName.contains(lowerSearch);
+ boolean categoryMatches = searchMatcher.matches(item.getText());
boolean hasMatchingContent = false;
// Check if category name matches
@@ -495,8 +497,7 @@ public class ConfigurationPerspective implements IHopPerspective {
boolean hasMatchingPlugin = false;
for (TreeItem childItem : item.getItems()) {
String pluginName = childItem.getText();
- String pluginNameLower = pluginName.toLowerCase();
- boolean pluginMatches = pluginNameLower.contains(lowerSearch);
+ boolean pluginMatches = searchMatcher.matches(pluginName);
// Also search within the plugin's content
boolean pluginContentMatches = searchInPluginContent(pluginName, lowerSearch);
@@ -614,7 +615,7 @@ public class ConfigurationPerspective implements IHopPerspective {
// Check labels
if (control instanceof Label label) {
String text = label.getText();
- if (text != null && text.toLowerCase().contains(searchText)) {
+ if (text != null && searchMatcher.matches(text)) {
matches.add(control);
controlMatches = true;
}
@@ -622,7 +623,7 @@ public class ConfigurationPerspective implements IHopPerspective {
// Check text fields
else if (control instanceof Text text) {
String value = text.getText();
- if (value != null && value.toLowerCase().contains(searchText)) {
+ if (value != null && searchMatcher.matches(value)) {
matches.add(control);
controlMatches = true;
}
@@ -630,7 +631,7 @@ public class ConfigurationPerspective implements IHopPerspective {
// Check buttons
else if (control instanceof Button button) {
String text = button.getText();
- if (text != null && text.toLowerCase().contains(searchText)) {
+ if (text != null && searchMatcher.matches(text)) {
matches.add(control);
controlMatches = true;
}
@@ -641,7 +642,7 @@ public class ConfigurationPerspective implements IHopPerspective {
for (TableItem item : table.getItems()) {
for (int i = 0; i < table.getColumnCount(); i++) {
String cellText = item.getText(i);
- if (cellText != null && cellText.toLowerCase().contains(searchText)) {
+ if (cellText != null && searchMatcher.matches(cellText)) {
matches.add(control);
controlMatches = true;
break;
@@ -659,7 +660,7 @@ public class ConfigurationPerspective implements IHopPerspective {
for (TableItem item : table.getItems()) {
for (int i = 0; i < table.getColumnCount(); i++) {
String cellText = item.getText(i);
- if (cellText != null && cellText.toLowerCase().contains(searchText)) {
+ if (cellText != null && searchMatcher.matches(cellText)) {
matches.add(control);
controlMatches = true;
break;
@@ -674,7 +675,7 @@ public class ConfigurationPerspective implements IHopPerspective {
// Check tooltips
if (!controlMatches) {
String tooltip = control.getToolTipText();
- if (tooltip != null && tooltip.toLowerCase().contains(searchText)) {
+ if (tooltip != null && searchMatcher.matches(tooltip)) {
matches.add(control);
controlMatches = true;
}
@@ -745,8 +746,6 @@ public class ConfigurationPerspective implements IHopPerspective {
return;
}
- String lowerSearch = currentSearchText.toLowerCase();
-
// Search through all table items and highlight matching rows
for (TableItem item : table.getItems()) {
boolean rowMatches = false;
@@ -754,7 +753,7 @@ public class ConfigurationPerspective implements IHopPerspective {
// Check all columns in the row
for (int i = 0; i < table.getColumnCount(); i++) {
String cellText = item.getText(i);
- if (cellText != null && cellText.toLowerCase().contains(lowerSearch)) {
+ if (cellText != null && searchMatcher.matches(cellText)) {
rowMatches = true;
break;
}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 30b02601b9..d33850c40b 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -53,6 +53,7 @@ import org.apache.hop.core.listeners.IContentChangedListener;
import org.apache.hop.core.plugins.IPlugin;
import org.apache.hop.core.plugins.PluginRegistry;
import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.SearchMatcher;
import org.apache.hop.core.svg.SvgCache;
import org.apache.hop.core.svg.SvgCacheEntry;
import org.apache.hop.core.svg.SvgFile;
@@ -113,6 +114,8 @@ import org.apache.hop.ui.hopgui.perspective.explorer.file.IExplorerFileTypeHandl
import org.apache.hop.ui.hopgui.perspective.explorer.file.types.FolderFileType;
import org.apache.hop.ui.hopgui.perspective.explorer.file.types.GenericFileType;
import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
+import org.apache.hop.ui.hopgui.search.HopGuiPipelineSearchable;
+import org.apache.hop.ui.hopgui.search.HopGuiWorkflowSearchable;
import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.SashFormMemory;
import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
@@ -256,6 +259,7 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
private Map<String, Image> typeImageMap;
private Text searchText;
private String filterText = "";
+ private SearchMatcher filterMatcher = new SearchMatcher("", false, false, true);
private Map<String, Boolean> treeStateBeforeFilter = null;
public ExplorerPerspective() {
@@ -534,7 +538,8 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
}
// Only filter when we have more than 2 characters, otherwise show all
- filterText = willFilter ? text.toLowerCase() : "";
+ filterText = willFilter ? text : "";
+ filterMatcher = new SearchMatcher(filterText, false, false, true);
refresh();
// Restore tree state after filtering ends
@@ -2783,6 +2788,7 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
searchText.setText("");
} else {
filterText = "";
+ filterMatcher = new SearchMatcher("", false, false, true);
}
}
@@ -2941,7 +2947,7 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
// Apply filter if search text is not empty
if (!Utils.isEmpty(filterText)
- && !childName.toLowerCase().contains(filterText)
+ && !filterMatcher.matches(childName)
&& !hasMatchingDescendant(child)) {
continue;
}
@@ -3145,7 +3151,7 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
}
// Check if this child matches
- if (childName.toLowerCase().contains(filterText)) {
+ if (filterMatcher.matches(childName)) {
return true;
}
@@ -3455,7 +3461,22 @@ public class ExplorerPerspective implements IHopPerspective, TabClosable, IFileD
@Override
public List<ISearchable> getSearchables() {
- return new ArrayList<>();
+ // Surface the pipelines and workflows that are currently open in tabs so they show up - and can
+ // be prioritized - in the global search.
+ List<ISearchable> searchables = new ArrayList<>();
+ for (TabItemHandler item : items) {
+ IHopFileTypeHandler typeHandler = item.getTypeHandler();
+ if (typeHandler == null) {
+ continue;
+ }
+ Object subject = typeHandler.getSubject();
+ if (subject instanceof PipelineMeta pipelineMeta) {
+ searchables.add(new HopGuiPipelineSearchable("Open file", pipelineMeta));
+ } else if (subject instanceof WorkflowMeta workflowMeta) {
+ searchables.add(new HopGuiWorkflowSearchable("Open file", workflowMeta));
+ }
+ }
+ return searchables;
}
/** Update HOP GUI menu and toolbar... */
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/search/HopSearchPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/search/HopSearchPerspective.java
deleted file mode 100644
index 803cd7a4ae..0000000000
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/search/HopSearchPerspective.java
+++ /dev/null
@@ -1,519 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.hop.ui.hopgui.perspective.search;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import org.apache.hop.core.Const;
-import org.apache.hop.core.gui.plugin.GuiPlugin;
-import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
-import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
-import org.apache.hop.core.plugins.IPlugin;
-import org.apache.hop.core.plugins.PluginRegistry;
-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.SearchableAnalyserPluginType;
-import org.apache.hop.core.util.Utils;
-import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.ui.core.PropsUi;
-import org.apache.hop.ui.core.bus.HopGuiEvents;
-import org.apache.hop.ui.core.dialog.ErrorDialog;
-import org.apache.hop.ui.core.gui.GuiResource;
-import org.apache.hop.ui.core.widget.ColumnInfo;
-import org.apache.hop.ui.core.widget.TableView;
-import org.apache.hop.ui.hopgui.HopGui;
-import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
-import org.apache.hop.ui.hopgui.perspective.HopPerspectivePlugin;
-import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
-import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
-import org.eclipse.swt.SWT;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
-import org.eclipse.swt.layout.FormLayout;
-import org.eclipse.swt.widgets.Button;
-import org.eclipse.swt.widgets.Combo;
-import org.eclipse.swt.widgets.Composite;
-import org.eclipse.swt.widgets.Control;
-import org.eclipse.swt.widgets.Event;
-import org.eclipse.swt.widgets.Label;
-import org.eclipse.swt.widgets.TableItem;
-
-@HopPerspectivePlugin(
- id = "400-HopSearchPerspective",
- name = "i18n::HopSearchPerspective.Name",
- description = "i18n::HopSearchPerspective.Description",
- image = "ui/images/search.svg",
- documentationUrl = "/hop-gui/perspective-search.html")
-@GuiPlugin(
- name = "i18n::HopSearchPerspective.Name",
- description = "i18n::HopSearchPerspective.GuiPlugin.Description")
-public class HopSearchPerspective implements IHopPerspective {
-
- private static final Class<?> PKG = HopSearchPerspective.class;
-
- public static final String ID_PERSPECTIVE_TOOLBAR_ITEM = "20020-perspective-search";
-
- public static final String AUDIT_TYPE_SEARCH_LOCATION = "search-location";
- public static final String AUDIT_TYPE_SEARCH_STRING = "search-string";
-
- private HopGui hopGui;
- private Composite composite;
- private List<ISearchablesLocation> searchablesLocations;
- private String[] locations;
- private Combo wLocations;
- private Combo wSearchString;
- private Button wCaseSensitive;
- private Button wRegEx;
- private TableView wResults;
- List<ISearchResult> allSearchResults;
-
- public HopSearchPerspective() {
- // Do nothing
- }
-
- @Override
- public String getId() {
- return "search";
- }
-
- @Override
- @GuiKeyboardShortcut(control = true, key = 'f', global = true)
- @GuiOsxKeyboardShortcut(command = true, key = 'f', global = true)
- public void activate() {
- // Someone clicked on the search icon of used CTRL-F
- //
- hopGui.setActivePerspective(this);
- }
-
- @Override
- public void perspectiveActivated() {
- wSearchString.setFocus();
-
- // Refresh the list of searchableLocations
- //
- searchablesLocations = hopGui.getSearchablesLocations();
- locations = new String[searchablesLocations.size()];
- for (int i = 0; i < locations.length; i++) {
- locations[i] = searchablesLocations.get(i).getLocationDescription();
- }
-
- refreshLastUsedLocation();
-
- refreshLastUsedSearchStrings();
- }
-
- private void refreshLastUsedLocation() {
- if (wLocations != null && !wLocations.isDisposed()) {
- wLocations.setItems(locations);
-
- String lastLocation = AuditManagerGuiUtil.getLastUsedValue(AUDIT_TYPE_SEARCH_LOCATION);
- if (lastLocation != null) {
- int index = Const.indexOfString(lastLocation, locations);
- if (index >= 0) {
- wLocations.select(index);
- }
- }
- }
- }
-
- private void refreshLastUsedSearchStrings() {
- if (wSearchString != null && !wSearchString.isDisposed()) {
- try {
- String[] lastUsedValues = AuditManagerGuiUtil.getLastUsedValues(AUDIT_TYPE_SEARCH_STRING);
- wSearchString.setItems(lastUsedValues);
- } catch (Exception e) {
- hopGui.getLog().logError("Error reading list of used search strings", e);
- }
- }
- }
-
- @Override
- public boolean isActive() {
- return hopGui.isActivePerspective(this);
- }
-
- @Override
- public void initialize(HopGui hopGui, Composite parent) {
- this.hopGui = hopGui;
-
- PropsUi props = PropsUi.getInstance();
-
- composite = new Composite(parent, SWT.NONE);
- PropsUi.setLook(composite);
- FormLayout layout = new FormLayout();
- layout.marginLeft = PropsUi.getMargin();
- layout.marginTop = PropsUi.getMargin();
- layout.marginLeft = PropsUi.getMargin();
- layout.marginBottom = PropsUi.getMargin();
- composite.setLayout(layout);
-
- FormData formData = new FormData();
- formData.left = new FormAttachment(0, 0);
- formData.top = new FormAttachment(0, 0);
- formData.right = new FormAttachment(100, 0);
- formData.bottom = new FormAttachment(100, 0);
- composite.setLayoutData(formData);
-
- int margin = (int) (PropsUi.getMargin() * props.getZoomFactor());
-
- // Add a simple label to test
- //
- Label wlInfo = new Label(composite, SWT.LEFT);
- PropsUi.setLook(wlInfo);
- wlInfo.setText(BaseMessages.getString(PKG, "HopSearchPerspective.Header.Description.Text"));
- wlInfo.setFont(GuiResource.getInstance().getFontBold());
- FormData fdInfo = new FormData();
- fdInfo.left = new FormAttachment(0, 0);
- fdInfo.right = new FormAttachment(100, 0);
- fdInfo.top = new FormAttachment(0, 0);
- wlInfo.setLayoutData(fdInfo);
- Control lastControl = wlInfo;
-
- Label wlSep1 = new Label(composite, SWT.SEPARATOR | SWT.HORIZONTAL);
- PropsUi.setLook(wlSep1);
- FormData fdlSep1 = new FormData();
- fdlSep1.left = new FormAttachment(0, 0);
- fdlSep1.right = new FormAttachment(100, 0);
- fdlSep1.top = new FormAttachment(lastControl, margin);
- wlSep1.setLayoutData(fdlSep1);
- lastControl = wlSep1;
-
- // The location
- //
- Label wlLocations = new Label(composite, SWT.LEFT);
- PropsUi.setLook(wlLocations);
- wlLocations.setText(BaseMessages.getString(PKG, "HopSearchPerspective.Label.Location"));
- FormData fdlLocations = new FormData();
- fdlLocations.left = new FormAttachment(0, 0);
- fdlLocations.top = new FormAttachment(lastControl, margin);
- wlLocations.setLayoutData(fdlLocations);
- lastControl = wlLocations;
-
- wLocations = new Combo(composite, SWT.BORDER);
- PropsUi.setLook(wLocations);
- FormData fdLocations = new FormData();
- fdLocations.left = new FormAttachment(0, 0);
- fdLocations.top = new FormAttachment(lastControl, margin);
- fdLocations.right = new FormAttachment(50, 0);
- wLocations.setLayoutData(fdLocations);
- lastControl = wLocations;
-
- // The search query
- //
- Label wlSearchString = new Label(composite, SWT.LEFT);
- PropsUi.setLook(wlSearchString);
- wlSearchString.setText(
- BaseMessages.getString(PKG, "HopSearchPerspective.SearchStringOptions.Description"));
- FormData fdlSearchString = new FormData();
- fdlSearchString.left = new FormAttachment(0, 0);
- fdlSearchString.top = new FormAttachment(lastControl, margin);
- wlSearchString.setLayoutData(fdlSearchString);
-
- wCaseSensitive = new Button(composite, SWT.CHECK);
- PropsUi.setLook(wCaseSensitive);
- wCaseSensitive.setText(
- BaseMessages.getString(PKG, "HopSearchPerspective.SearchStringOptions.Option1.Label"));
- FormData fdCaseSensitive = new FormData();
- fdCaseSensitive.left = new FormAttachment(wlSearchString, margin * 2);
- fdCaseSensitive.top = new FormAttachment(lastControl, margin);
- wCaseSensitive.setLayoutData(fdCaseSensitive);
-
- wRegEx = new Button(composite, SWT.CHECK);
- PropsUi.setLook(wRegEx);
- wRegEx.setText(
- BaseMessages.getString(PKG, "HopSearchPerspective.SearchStringOptions.Option2.Label"));
- FormData fdRegEx = new FormData();
- fdRegEx.left = new FormAttachment(wCaseSensitive, margin * 2);
- fdRegEx.top = new FormAttachment(lastControl, margin);
- wRegEx.setLayoutData(fdRegEx);
- lastControl = wCaseSensitive;
-
- wSearchString = new Combo(composite, SWT.BORDER | SWT.SINGLE);
- PropsUi.setLook(wSearchString);
- wSearchString.setFont(GuiResource.getInstance().getFontBold());
- FormData fdSearchString = new FormData();
- fdSearchString.left = new FormAttachment(0, 0);
- fdSearchString.top = new FormAttachment(lastControl, margin);
- fdSearchString.right = new FormAttachment(50, 0);
- wSearchString.setLayoutData(fdSearchString);
- wSearchString.addListener(SWT.DefaultSelection, this::search);
- lastControl = wSearchString;
-
- Button wbSearch = new Button(composite, SWT.PUSH);
- PropsUi.setLook(wbSearch);
- wbSearch.setText(BaseMessages.getString(PKG, "HopSearchPerspective.Search.Button.Label"));
- FormData fdbSearch = new FormData();
- fdbSearch.left = new FormAttachment(0, 0);
- fdbSearch.top = new FormAttachment(lastControl, margin);
- wbSearch.setLayoutData(fdbSearch);
- wbSearch.addListener(SWT.Selection, this::search);
- lastControl = wbSearch;
-
- // Disable search button if nothing to search
- wSearchString.addListener(
- SWT.Modify, e -> wbSearch.setEnabled(!Utils.isEmpty(wSearchString.getText())));
-
- Button wbOpen = new Button(composite, SWT.PUSH);
- PropsUi.setLook(wbOpen);
- wbOpen.setText(BaseMessages.getString(PKG, "HopSearchPerspective.Open.Button.Label"));
- FormData fdbOpen = new FormData();
- fdbOpen.left = new FormAttachment(50, 0);
- fdbOpen.bottom = new FormAttachment(100, -margin);
- wbOpen.setLayoutData(fdbOpen);
- wbOpen.addListener(SWT.Selection, this::open);
- wbOpen.setEnabled(false);
-
- // A table with the search results...
- //
- ColumnInfo[] resultsColumns = {
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.Type.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.Name.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.File.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.Location.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.MatchText.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.Value.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- new ColumnInfo(
- BaseMessages.getString(PKG, "HopSearchPerspective.ResultsTable.Description.Field"),
- ColumnInfo.COLUMN_TYPE_TEXT,
- false,
- true),
- };
-
- wResults =
- new TableView(
- hopGui.getVariables(),
- composite,
- SWT.V_SCROLL | SWT.MULTI,
- resultsColumns,
- 0,
- null,
- props);
- PropsUi.setLook(wResults);
- wResults.setReadonly(true);
- FormData fdResults = new FormData();
- fdResults.left = new FormAttachment(0, 0);
- fdResults.right = new FormAttachment(100, 0);
- fdResults.top = new FormAttachment(lastControl, margin);
- fdResults.bottom = new FormAttachment(wbOpen, -2 * margin);
- wResults.setLayoutData(fdResults);
- wResults.table.addListener(
- SWT.Selection, e -> wbOpen.setEnabled(wResults.getSelectionIndices().length == 1));
- wResults.table.addListener(SWT.DefaultSelection, this::open);
-
- hopGui
- .getEventsHandler()
- .addEventListener(
- getClass().getName() + "ProjectActivated",
- e -> hopGui.getDisplay().asyncExec(this::clearSearchFilters),
- HopGuiEvents.ProjectActivated.name());
- }
-
- @Override
- public void clearSearchFilters() {
- if (wSearchString != null && !wSearchString.isDisposed()) {
- wSearchString.setText("");
- }
- if (wResults != null && !wResults.isDisposed()) {
- wResults.table.removeAll();
- }
- allSearchResults = new ArrayList<>();
- }
-
- private void open(Event event) {
- int index = wResults.getSelectionIndex();
- if (index < 0) {
- return;
- }
- TableItem tableItem = wResults.table.getSelection()[0];
-
- // Open the selected item.
- //
- int itemNr = Const.toInt(tableItem.getText(0), -1);
- if (itemNr <= 0) {
- return;
- }
-
- ISearchResult searchResult = allSearchResults.get(itemNr - 1);
- ISearchable searchable = searchResult.getMatchingSearchable();
- try {
- searchable.getSearchCallback().callback(searchable, searchResult);
- } catch (Exception e) {
- new ErrorDialog(hopGui.getActiveShell(), "Error", "Error opening " + searchable.getName(), e);
- }
- }
-
- private void search(Event event) {
-
- // Nothing to search
- if (Utils.isEmpty(wSearchString.getText())) return;
-
- // Find the search location using the combo
- // Get the list of searchables.
- //
- boolean caseSensitive = wCaseSensitive.getSelection();
- boolean regularExpression = wRegEx.getSelection();
-
- SearchQuery searchQuery =
- new SearchQuery(wSearchString.getText(), caseSensitive, regularExpression);
-
- wResults.table.removeAll();
- allSearchResults = new ArrayList<>();
-
- try {
- // What is the list of available searchable analysers?
- //
- Map<Class<ISearchableAnalyser>, ISearchableAnalyser> searchableAnalyserMap = new HashMap<>();
- PluginRegistry registry = PluginRegistry.getInstance();
- for (IPlugin analyserPlugin : registry.getPlugins(SearchableAnalyserPluginType.class)) {
- ISearchableAnalyser searchableAnalyser =
- (ISearchableAnalyser) registry.loadClass(analyserPlugin);
- searchableAnalyserMap.put(searchableAnalyser.getSearchableClass(), searchableAnalyser);
- }
-
- ISearchablesLocation searchablesLocation = getSelectedSearchLocation();
- if (searchablesLocation == null) {
- return;
- }
-
- // Save the last used location and search string...
- //
- AuditManagerGuiUtil.addLastUsedValue(
- AUDIT_TYPE_SEARCH_LOCATION, searchablesLocation.getLocationDescription());
- AuditManagerGuiUtil.addLastUsedValue(AUDIT_TYPE_SEARCH_STRING, wSearchString.getText());
-
- Iterator<ISearchable> iterator =
- searchablesLocation.getSearchables(hopGui.getMetadataProvider(), hopGui.getVariables());
- while (iterator.hasNext()) {
- // Load the next object
- //
- ISearchable searchable = iterator.next();
-
- Object object = searchable.getSearchableObject();
- if (object != null) {
- // Find an analyser...
- //
- ISearchableAnalyser searchableAnalyser = searchableAnalyserMap.get(object.getClass());
- if (searchableAnalyser != null) {
- List<ISearchResult> searchResults = searchableAnalyser.search(searchable, searchQuery);
- addSearchResults(searchResults);
- }
- }
- }
- wResults.removeEmptyRows();
- wResults.setRowNums();
- wResults.optWidth(true);
- } catch (Exception e) {
- new ErrorDialog(hopGui.getActiveShell(), "Error", "Error searching", e);
- } finally {
- refreshLastUsedLocation();
- refreshLastUsedSearchStrings();
- }
- }
-
- private ISearchablesLocation getSelectedSearchLocation() {
- String locationDescription = wLocations.getText();
- for (ISearchablesLocation searchablesLocation : searchablesLocations) {
- if (searchablesLocation.getLocationDescription().equalsIgnoreCase(locationDescription)) {
- return searchablesLocation;
- }
- }
- return null;
- }
-
- private void addSearchResults(List<ISearchResult> searchResults) {
-
- for (ISearchResult searchResult : searchResults) {
- ISearchable searchable = searchResult.getMatchingSearchable();
-
- TableItem item = new TableItem(wResults.table, SWT.NONE);
- int c = 1;
- item.setText(c++, Const.NVL(searchable.getType(), ""));
- item.setText(c++, Const.NVL(searchable.getName(), ""));
- item.setText(c++, Const.NVL(searchable.getFilename(), ""));
- item.setText(c++, Const.NVL(searchable.getLocation(), ""));
- item.setText(c++, Const.NVL(searchResult.getMatchingString(), ""));
- item.setText(c++, Const.NVL(searchResult.getValue(), ""));
- item.setText(
- c++,
- Const.NVL(Const.NVL(searchResult.getComponent(), searchResult.getDescription()), ""));
-
- allSearchResults.add(searchResult);
- }
- }
-
- /**
- * Gets hopGui
- *
- * @return value of hopGui
- */
- public HopGui getHopGui() {
- return hopGui;
- }
-
- /**
- * @param hopGui The hopGui to set
- */
- public void setHopGui(HopGui hopGui) {
- this.hopGui = hopGui;
- }
-
- @Override
- public Control getControl() {
- return composite;
- }
-
- @Override
- public List<IGuiContextHandler> getContextHandlers() {
- return new ArrayList<>();
- }
-
- @Override
- public List<ISearchable> getSearchables() {
- return new ArrayList<>();
- }
-}
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
new file mode 100644
index 0000000000..fce4d42a30
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
@@ -0,0 +1,621 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.search;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.search.ISearchQuery;
+import org.apache.hop.core.search.ISearchResult;
+import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableAnalyser;
+import org.apache.hop.core.search.ISearchablesLocation;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.core.search.SearchResult;
+import org.apache.hop.core.search.SearchableAnalyserPluginType;
+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.file.workflow.HopWorkflowFileType;
+import org.apache.hop.workflow.WorkflowMeta;
+
+/**
+ * Shared logic to run a search against a {@link ISearchablesLocation} using the registered {@link
+ * ISearchableAnalyser} plugins. Used by both the dedicated search perspective and the global search
+ * popup so they behave identically.
+ */
+public final class HopGuiSearchHelper {
+
+ private HopGuiSearchHelper() {
+ // Utility class
+ }
+
+ /**
+ * Load the registered searchable analysers, mapped by the class of object they can analyse.
+ *
+ * @return a map of searchable object class to its analyser
+ * @throws HopException in case an analyser plugin can't be loaded
+ */
+ public static Map<Class<ISearchableAnalyser>, ISearchableAnalyser> loadSearchableAnalysers()
+ throws HopException {
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap = new HashMap<>();
+ PluginRegistry registry = PluginRegistry.getInstance();
+ for (IPlugin analyserPlugin : registry.getPlugins(SearchableAnalyserPluginType.class)) {
+ ISearchableAnalyser searchableAnalyser =
+ (ISearchableAnalyser) registry.loadClass(analyserPlugin);
+ analyserMap.put(searchableAnalyser.getSearchableClass(), searchableAnalyser);
+ }
+ return analyserMap;
+ }
+
+ /**
+ * Search a single location and collect all the matching results.
+ *
+ * @param location the location to enumerate searchables from
+ * @param query the search query
+ * @param analyserMap the analysers to use, see {@link #loadSearchableAnalysers()}
+ * @param metadataProvider the metadata provider
+ * @param variables the variables
+ * @return the list of search results, never null
+ * @throws HopException in case something goes wrong while searching
+ */
+ public static List<ISearchResult> searchLocation(
+ ISearchablesLocation location,
+ ISearchQuery query,
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap,
+ IHopMetadataProvider metadataProvider,
+ IVariables variables)
+ throws HopException {
+ return analyse(enumerateSearchables(location, metadataProvider, variables), query, analyserMap);
+ }
+
+ /**
+ * Enumerate (load) all the searchable objects of a location. This is the potentially expensive
+ * part (it may load pipelines/workflows and metadata from disk), so callers that search
+ * repeatedly - such as the live search popup - should enumerate once and reuse the result with
+ * {@link #analyse(java.util.Collection, ISearchQuery, Map)}.
+ *
+ * @param location the location to enumerate
+ * @param metadataProvider the metadata provider
+ * @param variables the variables
+ * @return the list of searchables
+ * @throws HopException in case the location can't be enumerated
+ */
+ public static List<ISearchable> enumerateSearchables(
+ ISearchablesLocation location, IHopMetadataProvider metadataProvider, IVariables variables)
+ throws HopException {
+ List<ISearchable> searchables = new ArrayList<>();
+ Iterator<ISearchable> iterator = location.getSearchables(metadataProvider, variables);
+ while (iterator.hasNext()) {
+ searchables.add(iterator.next());
+ }
+ return searchables;
+ }
+
+ /**
+ * Run the (cheap, in-memory) analyser matching over a set of already-enumerated searchables.
+ *
+ * @param searchables the searchables to match against, see {@link #enumerateSearchables}
+ * @param query the search query
+ * @param analyserMap the analysers to use, see {@link #loadSearchableAnalysers()}
+ * @return the list of matching results, never null
+ */
+ public static List<ISearchResult> analyse(
+ Collection<ISearchable> searchables,
+ ISearchQuery query,
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap) {
+ List<ISearchResult> results = new ArrayList<>();
+ for (ISearchable searchable : searchables) {
+ Object object = searchable.getSearchableObject();
+ if (object != null) {
+ ISearchableAnalyser searchableAnalyser = analyserMap.get(object.getClass());
+ if (searchableAnalyser != null) {
+ results.addAll(searchableAnalyser.search(searchable, query));
+ }
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Like {@link #analyse} but additionally (when {@code fuzzyNames} is on and not a regex search)
+ * matches object <em>names</em> fuzzily - so a typo'd name still turns up - and sorts every
+ * result by relevance: name matches first (ranked by how well the name matches), then content
+ * matches.
+ *
+ * @param searchables the searchables to match against
+ * @param query the search query
+ * @param analyserMap the analysers to use
+ * @param fuzzyNames whether to add fuzzy name matches and rank results
+ * @return the ranked list of results, never null
+ */
+ public static List<ISearchResult> analyseRanked(
+ Collection<ISearchable> searchables,
+ ISearchQuery query,
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap,
+ boolean fuzzyNames) {
+ List<ISearchResult> results = new ArrayList<>(analyse(searchables, query, analyserMap));
+
+ SearchMatcher nameMatcher =
+ new SearchMatcher(query.getSearchString(), query.isCaseSensitive(), query.isRegEx(), true);
+
+ if (fuzzyNames && !query.isRegEx()) {
+ Set<String> covered = new HashSet<>();
+ for (ISearchResult result : results) {
+ covered.add(searchableKey(result.getMatchingSearchable()));
+ }
+ for (ISearchable searchable : searchables) {
+ String name = searchable.getName();
+ if (name != null
+ && covered.add(searchableKey(searchable))
+ && nameMatcher.score(name) > 0.0) {
+ // A name that matches (often only fuzzily) without any other hit on the object.
+ results.add(new SearchResult(searchable, name, "name", null, name));
+ }
+ }
+ }
+
+ Map<ISearchResult, Double> relevance = new IdentityHashMap<>();
+ for (ISearchResult result : results) {
+ relevance.put(result, relevance(result, nameMatcher));
+ }
+ results.sort((a, b) -> Double.compare(relevance.get(b), relevance.get(a)));
+ return results;
+ }
+
+ /**
+ * Relevance score for ranking: name matches (ranked by name similarity) outrank content matches.
+ */
+ private static double relevance(ISearchResult result, SearchMatcher nameMatcher) {
+ ISearchable searchable = result.getMatchingSearchable();
+ String name = Const.NVL(searchable.getName(), "");
+ boolean isNameMatch = name.equalsIgnoreCase(Const.NVL(result.getMatchingString(), ""));
+ if (isNameMatch) {
+ return 0.5 + 0.5 * Math.max(0.0, nameMatcher.score(name));
+ }
+ return 0.3;
+ }
+
+ /** A stable identity for a searchable (type + name + normalized path) used for de-duplication. */
+ public static String searchableKey(ISearchable searchable) {
+ return Const.NVL(searchable.getType(), "")
+ + "|"
+ + Const.NVL(searchable.getName(), "")
+ + "|"
+ + stripScheme(Const.NVL(searchable.getFilename(), "").replace('\\', '/'));
+ }
+
+ /** Audit type under which the shared search-string history is stored (popup + results panel). */
+ public static final String AUDIT_TYPE_SEARCH_STRING = "search-string";
+
+ // --- 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. */
+ public static final String SECTION_OPEN = "open";
+
+ /** Internal section key for everything else (project files, metadata, variables, ...). */
+ public static final String SECTION_PROJECT = "project";
+
+ /**
+ * Enumerate the searchables of <em>all</em> the given locations once, de-duplicating objects that
+ * several locations report. The first occurrence wins; since the GUI location (open files) is
+ * passed first, an open file is kept over the same file found on disk. For every kept searchable
+ * the index of the location it came from is recorded (0 = the first/GUI location), which lets
+ * {@link #isOpenObject} tell open files apart from project files.
+ *
+ * @param locations the locations to enumerate, GUI location first
+ * @param metadataProvider the metadata provider
+ * @param variables the variables
+ * @param log the log channel for per-location errors (may be null)
+ * @return the de-duplicated searchables and their source-location indexes
+ */
+ public static EnumeratedSearchables enumerateAll(
+ List<ISearchablesLocation> locations,
+ IHopMetadataProvider metadataProvider,
+ IVariables variables,
+ ILogChannel log) {
+ List<ISearchable> searchables = new ArrayList<>();
+ Map<String, Integer> sourceByKey = new HashMap<>();
+ Set<String> seen = new HashSet<>();
+ for (int locationIndex = 0; locationIndex < locations.size(); locationIndex++) {
+ ISearchablesLocation location = locations.get(locationIndex);
+ try {
+ for (ISearchable searchable : enumerateSearchables(location, metadataProvider, variables)) {
+ String key = searchableKey(searchable);
+ if (seen.add(key)) {
+ searchables.add(searchable);
+ sourceByKey.put(key, locationIndex);
+ }
+ }
+ } catch (Exception e) {
+ if (log != null) {
+ log.logError(
+ "Error loading searchables from location " + location.getLocationDescription(), e);
+ }
+ }
+ }
+ return new EnumeratedSearchables(searchables, sourceByKey);
+ }
+
+ /**
+ * Whether a searchable is an object that is currently <em>open</em> in a Hop GUI tab. Only files
+ * (pipelines and workflows) can be open; metadata items and variables are always project-wide.
+ *
+ * @param searchable the searchable to classify
+ * @param sourceByKey the source-location map from {@link #enumerateAll}
+ * @return true if this is an open pipeline/workflow tab
+ */
+ public static boolean isOpenObject(ISearchable searchable, Map<String, Integer> sourceByKey) {
+ Object object = searchable.getSearchableObject();
+ if (object instanceof PipelineMeta || object instanceof WorkflowMeta) {
+ Integer source = sourceByKey.get(searchableKey(searchable));
+ return source != null && source == 0;
+ }
+ return false;
+ }
+
+ /** Whether a result matched the object's own <em>name</em> (rather than some inner field). */
+ public static boolean isNameMatch(ISearchResult result) {
+ String name = Const.NVL(result.getMatchingSearchable().getName(), "");
+ return !name.isEmpty() && name.equalsIgnoreCase(Const.NVL(result.getMatchingString(), ""));
+ }
+
+ /**
+ * Turn a flat (ranked) list of results into the two-tier tree both search windows render: section
+ * ({@link #SECTION_OPEN} / {@link #SECTION_PROJECT}) → type (Pipeline, Workflow, metadata
+ * type, ...) → object → the individual field/transform matches inside it. A match on
+ * the object's own name does not become a child row; it just marks the object node.
+ *
+ * @param ranked the ranked results, see {@link #analyseRanked}
+ * @param sourceByKey the source-location map from {@link #enumerateAll}
+ * @return the ordered sections, never null
+ */
+ public static List<SearchSection> groupResults(
+ List<ISearchResult> ranked, Map<String, Integer> sourceByKey) {
+ // Bucket results by object, keeping the (rank) order in which objects first appear.
+ Map<String, ObjectAccumulator> byObject = new LinkedHashMap<>();
+ for (ISearchResult result : ranked) {
+ String key = searchableKey(result.getMatchingSearchable());
+ ObjectAccumulator accumulator =
+ byObject.computeIfAbsent(key, k -> new ObjectAccumulator(result.getMatchingSearchable()));
+ if (isNameMatch(result)) {
+ if (accumulator.nameMatch == null) {
+ accumulator.nameMatch = result;
+ }
+ } else if (accumulator.seenMatchKeys.add(matchIdentity(result))) {
+ // Collapse matches that point at the same component with the same matched text - e.g. a
+ // transform whose own name AND its plugin name both equal the query produce two otherwise
+ // identical-looking rows.
+ accumulator.matches.add(result);
+ }
+ }
+
+ // section -> type -> objects, preserving rank order of objects within a type.
+ Map<String, Map<String, List<SearchObjectGroup>>> sections = new LinkedHashMap<>();
+ sections.put(SECTION_OPEN, new LinkedHashMap<>());
+ sections.put(SECTION_PROJECT, new LinkedHashMap<>());
+ for (ObjectAccumulator accumulator : byObject.values()) {
+ ISearchResult primary =
+ accumulator.nameMatch != null
+ ? accumulator.nameMatch
+ : (accumulator.matches.isEmpty() ? null : accumulator.matches.get(0));
+ if (primary == null) {
+ continue;
+ }
+ String sectionKey =
+ isOpenObject(accumulator.searchable, sourceByKey) ? SECTION_OPEN : SECTION_PROJECT;
+ String type = Const.NVL(accumulator.searchable.getType(), "");
+ sections
+ .get(sectionKey)
+ .computeIfAbsent(type, t -> new ArrayList<>())
+ .add(new SearchObjectGroup(accumulator.searchable, primary, accumulator.matches));
+ }
+
+ List<SearchSection> result = new ArrayList<>();
+ for (String sectionKey : new String[] {SECTION_OPEN, SECTION_PROJECT}) {
+ Map<String, List<SearchObjectGroup>> typeMap = sections.get(sectionKey);
+ if (typeMap.isEmpty()) {
+ continue;
+ }
+ List<String> types = new ArrayList<>(typeMap.keySet());
+ types.sort(
+ Comparator.comparingInt(HopGuiSearchHelper::typeRank)
+ .thenComparing(t -> t.toLowerCase(Locale.ROOT)));
+ List<SearchTypeGroup> typeGroups = new ArrayList<>();
+ for (String type : types) {
+ typeGroups.add(new SearchTypeGroup(type, typeMap.get(type)));
+ }
+ result.add(new SearchSection(sectionKey, typeGroups));
+ }
+ return result;
+ }
+
+ /**
+ * Sort order of type groups within a section: pipelines, workflows, then the rest, variables
+ * last.
+ */
+ private static int typeRank(String type) {
+ if (HopPipelineFileType.PIPELINE_FILE_TYPE_DESCRIPTION.equals(type)) {
+ return 0;
+ }
+ if (HopWorkflowFileType.WORKFLOW_FILE_TYPE_DESCRIPTION.equals(type)) {
+ return 1;
+ }
+ if ("Variable".equals(type)) {
+ return 100;
+ }
+ return 50;
+ }
+
+ /** A match's user-visible identity within its object: where it is plus what matched. */
+ private static String matchIdentity(ISearchResult result) {
+ return Const.NVL(result.getComponent(), "") + "