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}) &#8594; type (Pipeline, Workflow, metadata
+   * type, ...) &#8594; object &#8594; 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(), "") + "" + Const.NVL(result.getMatchingString(), "");
+  }
+
+  /** Mutable per-object bucket used only while {@link #groupResults grouping}. */
+  private static final class ObjectAccumulator {
+    private final ISearchable searchable;
+    private ISearchResult nameMatch;
+    private final List<ISearchResult> matches = new ArrayList<>();
+    private final Set<String> seenMatchKeys = new HashSet<>();
+
+    private ObjectAccumulator(ISearchable searchable) {
+      this.searchable = searchable;
+    }
+  }
+
+  /** The de-duplicated searchables of all locations plus where each came from. */
+  public static final class EnumeratedSearchables {
+    private final List<ISearchable> searchables;
+    private final Map<String, Integer> sourceByKey;
+
+    public EnumeratedSearchables(List<ISearchable> searchables, Map<String, Integer> sourceByKey) {
+      this.searchables = searchables;
+      this.sourceByKey = sourceByKey;
+    }
+
+    public List<ISearchable> getSearchables() {
+      return searchables;
+    }
+
+    public Map<String, Integer> getSourceByKey() {
+      return sourceByKey;
+    }
+  }
+
+  /** A top-level section (Open / Project) of the grouped results. */
+  public static final class SearchSection {
+    private final String key;
+    private final List<SearchTypeGroup> typeGroups;
+
+    private SearchSection(String key, List<SearchTypeGroup> typeGroups) {
+      this.key = key;
+      this.typeGroups = typeGroups;
+    }
+
+    public String getKey() {
+      return key;
+    }
+
+    public List<SearchTypeGroup> getTypeGroups() {
+      return typeGroups;
+    }
+
+    /** The number of matching objects in this section (across all its type groups). */
+    public int getObjectCount() {
+      int count = 0;
+      for (SearchTypeGroup typeGroup : typeGroups) {
+        count += typeGroup.getObjects().size();
+      }
+      return count;
+    }
+  }
+
+  /** A type group (Pipeline, Workflow, a metadata type, Variable, ...) within a section. */
+  public static final class SearchTypeGroup {
+    private final String type;
+    private final List<SearchObjectGroup> objects;
+
+    private SearchTypeGroup(String type, List<SearchObjectGroup> objects) {
+      this.type = type;
+      this.objects = objects;
+    }
+
+    public String getType() {
+      return type;
+    }
+
+    public List<SearchObjectGroup> getObjects() {
+      return objects;
+    }
+  }
+
+  /** A single matching object and the individual field/transform matches found inside it. */
+  public static final class SearchObjectGroup {
+    private final ISearchable searchable;
+    private final ISearchResult primary;
+    private final List<ISearchResult> matches;
+
+    private SearchObjectGroup(
+        ISearchable searchable, ISearchResult primary, List<ISearchResult> matches) {
+      this.searchable = searchable;
+      this.primary = primary;
+      this.matches = matches;
+    }
+
+    public ISearchable getSearchable() {
+      return searchable;
+    }
+
+    /**
+     * The representative result for the object node itself (its name match, else its first match).
+     */
+    public ISearchResult getPrimary() {
+      return primary;
+    }
+
+    /** The inner matches shown as child rows (empty when only the object's name matched). */
+    public List<ISearchResult> getMatches() {
+      return matches;
+    }
+  }
+
+  /**
+   * A condensed, human-readable description of <em>where</em> a result matched: the component (e.g.
+   * a transform name) and the field, for example "Detect Language &#8594; url" or "description".
+   *
+   * @param result the search result
+   * @return the condensed location of the match, never null
+   */
+  public static String matchWhere(ISearchResult result) {
+    String field = result.getDescription();
+    if (field != null) {
+      int idx = field.lastIndexOf(" : ");
+      if (idx >= 0) {
+        field = field.substring(idx + 3);
+      }
+    }
+    field = Const.NVL(field, "");
+    String component = result.getComponent();
+    if (component != null && !component.isEmpty()) {
+      return field.isEmpty() ? component : component + " → " + field;
+    }
+    return field;
+  }
+
+  /**
+   * A short snippet of the matched text, windowed around the search term where possible, so the
+   * user can see <em>what</em> matched without opening the object.
+   *
+   * @param result the search result
+   * @param searchTerm the (plain, non-regex) search term, used to center the snippet; may be null
+   * @return the condensed match snippet, never null
+   */
+  public static String matchSnippet(ISearchResult result, String searchTerm) {
+    String value = Const.NVL(result.getValue(), result.getMatchingString());
+    if (value == null) {
+      return "";
+    }
+    value = value.replaceAll("\\s+", " ").trim();
+    int window = 70;
+    if (searchTerm != null && !searchTerm.isEmpty()) {
+      int idx = value.toLowerCase(Locale.ROOT).indexOf(searchTerm.toLowerCase(Locale.ROOT));
+      if (idx >= 0) {
+        int start = Math.max(0, idx - 25);
+        int end = Math.min(value.length(), idx + searchTerm.length() + 40);
+        String snippet = value.substring(start, end);
+        if (start > 0) {
+          snippet = "…" + snippet;
+        }
+        if (end < value.length()) {
+          snippet = snippet + "…";
+        }
+        return snippet;
+      }
+    }
+    return value.length() > window ? value.substring(0, window) + "…" : value;
+  }
+
+  /**
+   * Make a file path nicer to display: strip the {@code file://} scheme and, when the path is
+   * inside the current project, replace the project home prefix with the literal {@code
+   * ${PROJECT_HOME}}.
+   *
+   * @param rawPath the path to sanitize (may be a {@code file://} URI)
+   * @param variables the variables used to resolve {@code ${PROJECT_HOME}}
+   * @return the sanitized, project-relative path
+   */
+  public static String sanitizePath(String rawPath, IVariables variables) {
+    if (rawPath == null || rawPath.isEmpty()) {
+      return Const.NVL(rawPath, "");
+    }
+    String path = stripScheme(rawPath.replace('\\', '/'));
+    if (variables != null) {
+      String home = variables.resolve(Const.VAR_PROJECT_HOME);
+      if (home != null && !home.isEmpty() && !Const.VAR_PROJECT_HOME.equals(home)) {
+        home = stripScheme(home.replace('\\', '/'));
+        if (path.startsWith(home)) {
+          String rel = path.substring(home.length());
+          return Const.VAR_PROJECT_HOME + (rel.startsWith("/") ? rel : "/" + rel);
+        }
+      }
+    }
+    return path;
+  }
+
+  private static String stripScheme(String path) {
+    if (path.startsWith("file://")) {
+      return path.substring("file://".length());
+    }
+    if (path.startsWith("file:")) {
+      return path.substring("file:".length());
+    }
+    return path;
+  }
+
+  /**
+   * The label to show for an object in the results tree: its name, prefixed with the folder path
+   * relative to the project home when it is a project file. This disambiguates several
+   * pipelines/workflows that share a name in different folders (e.g. {@code data/template} vs
+   * {@code samples/template}). Non-file searchables (metadata, variables) and files outside the
+   * project just show their name.
+   *
+   * @param searchable the searchable to label
+   * @param variables the variables used to resolve {@code ${PROJECT_HOME}}
+   * @return the display label
+   */
+  public static String displayLabel(ISearchable searchable, IVariables variables) {
+    String name = Const.NVL(searchable.getName(), "");
+    String filename = searchable.getFilename();
+    if (filename == null || filename.isEmpty()) {
+      return name;
+    }
+    String folder = relativeFolder(filename, variables);
+    return folder.isEmpty() ? name : folder + name;
+  }
+
+  /** The folder of a file relative to the project home (with a trailing slash), or "" otherwise. */
+  private static String relativeFolder(String rawPath, IVariables variables) {
+    String sanitized = sanitizePath(rawPath, variables);
+    if (!sanitized.startsWith(Const.VAR_PROJECT_HOME)) {
+      // Outside the project (absolute path): keep just the name rather than a long prefix.
+      return "";
+    }
+    String rel = sanitized.substring(Const.VAR_PROJECT_HOME.length());
+    if (rel.startsWith("/")) {
+      rel = rel.substring(1);
+    }
+    int lastSlash = rel.lastIndexOf('/');
+    return lastSlash < 0 ? "" : rel.substring(0, lastSlash + 1);
+  }
+}
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
new file mode 100644
index 0000000000..f97d8552e8
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
@@ -0,0 +1,574 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.search;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+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.history.AuditManager;
+import org.apache.hop.history.AuditState;
+import org.apache.hop.history.AuditStateMap;
+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.gui.HopNamespace;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeColumn;
+import org.eclipse.swt.widgets.TreeItem;
+
+/**
+ * The full search-results view (toolbar + grouped result tree + details panel), as a reusable
+ * {@link Composite}. It runs the same searchable-analyser pipeline as the {@link
+ * SearchEverywhereDialog} popup across all locations and renders the ranked results as the two-tier
+ * section &#8594; type &#8594; object &#8594; matches tree.
+ *
+ * <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.
+ */
+public class HopGuiSearchResultsPanel extends Composite {
+
+  private static final Class<?> PKG = HopGuiSearchResultsPanel.class;
+
+  /** Persisted state (sash weights). Kept under the old key so existing weights still load. */
+  private static final String SEARCH_AUDIT_TYPE = "search-perspective-state";
+
+  private static final String STATE_SASH_WEIGHTS_KEY = "sash-weights";
+  private static final String STATE_SASH_WEIGHTS_PROP = "weights";
+  private static final int[] DEFAULT_SASH_WEIGHTS = {70, 30};
+
+  /** Debounce in ms between a keystroke and running the search. */
+  private static final int SEARCH_DELAY_MS = 300;
+
+  private final HopGui hopGui;
+  private final String eventListenerId;
+  private List<ISearchablesLocation> searchablesLocations;
+
+  private Combo wSearchString;
+  private Button wCaseSensitive;
+  private Button wRegEx;
+  private SashForm sash;
+  private Tree wTree;
+  private Button wbOpen;
+
+  /** Read-only labels that make up the details panel on the right. */
+  private Label wdType;
+
+  private Label wdName;
+  private Label wdFile;
+  private Label wdLocation;
+  private Label wdMatch;
+  private Label wdValue;
+  private Label wdDescription;
+
+  /** Searchables enumerated once across all locations and reused for every keystroke. */
+  private HopGuiSearchHelper.EnumeratedSearchables cachedEnumeration;
+
+  private Map<Class<ISearchableAnalyser>, ISearchableAnalyser> cachedAnalysers;
+
+  private final Runnable searchRunnable = () -> search(new Event());
+
+  public HopGuiSearchResultsPanel(Composite parent, HopGui hopGui) {
+    super(parent, SWT.NONE);
+    this.hopGui = hopGui;
+    this.searchablesLocations = hopGui.getSearchablesLocations();
+    this.eventListenerId = getClass().getName() + "-" + System.identityHashCode(this);
+
+    buildUi();
+
+    // Clear results when the active project changes (the searchables become stale).
+    hopGui
+        .getEventsHandler()
+        .addEventListener(
+            eventListenerId,
+            e -> hopGui.getDisplay().asyncExec(this::clearSearchFilters),
+            HopGuiEvents.ProjectActivated.name());
+
+    addDisposeListener(
+        e -> {
+          // The Dispose event fires before the child sash is released, so weights are still valid.
+          saveState();
+          hopGui.getEventsHandler().removeEventListeners(eventListenerId);
+        });
+  }
+
+  private void buildUi() {
+    PropsUi props = PropsUi.getInstance();
+    int margin = PropsUi.getMargin();
+
+    PropsUi.setLook(this);
+    FormLayout layout = new FormLayout();
+    layout.marginLeft = margin;
+    layout.marginTop = margin;
+    layout.marginRight = margin;
+    layout.marginBottom = margin;
+    setLayout(layout);
+
+    // --- Top toolbar: search field, case/regex toggles, refresh ---
+    //
+    Composite toolbar = new Composite(this, SWT.NONE);
+    PropsUi.setLook(toolbar);
+    toolbar.setLayout(new GridLayout(5, false));
+    FormData fdToolbar = new FormData();
+    fdToolbar.left = new FormAttachment(0, 0);
+    fdToolbar.right = new FormAttachment(100, 0);
+    fdToolbar.top = new FormAttachment(0, 0);
+    toolbar.setLayoutData(fdToolbar);
+
+    Label wlSearch = new Label(toolbar, SWT.LEFT);
+    PropsUi.setLook(wlSearch);
+    wlSearch.setText(BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Label.Search"));
+
+    wSearchString = new Combo(toolbar, SWT.BORDER | SWT.SINGLE);
+    PropsUi.setLook(wSearchString);
+    wSearchString.setFont(GuiResource.getInstance().getFontBold());
+    wSearchString.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+    wSearchString.addListener(SWT.Modify, e -> scheduleSearch());
+    wSearchString.addListener(
+        SWT.DefaultSelection,
+        e -> {
+          persistSearchString();
+          search(e);
+        });
+
+    wCaseSensitive = new Button(toolbar, SWT.CHECK);
+    PropsUi.setLook(wCaseSensitive);
+    wCaseSensitive.setText(
+        BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Option.CaseSensitive"));
+    wCaseSensitive.addListener(SWT.Selection, e -> scheduleSearch());
+
+    wRegEx = new Button(toolbar, SWT.CHECK);
+    PropsUi.setLook(wRegEx);
+    wRegEx.setText(BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Option.RegEx"));
+    wRegEx.addListener(SWT.Selection, e -> scheduleSearch());
+
+    Button wbRefresh = new Button(toolbar, SWT.PUSH);
+    PropsUi.setLook(wbRefresh);
+    wbRefresh.setText(BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Refresh.Button.Label"));
+    wbRefresh.setToolTipText(
+        BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Refresh.Button.Tooltip"));
+    wbRefresh.addListener(
+        SWT.Selection,
+        e -> {
+          invalidateCache();
+          search(e);
+        });
+
+    // --- Bottom: Open button ---
+    //
+    wbOpen = new Button(this, 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);
+
+    // --- Middle: grouped result tree | details panel ---
+    //
+    sash = new SashForm(this, SWT.HORIZONTAL);
+    PropsUi.setLook(sash);
+    FormData fdSash = new FormData();
+    fdSash.left = new FormAttachment(0, 0);
+    fdSash.right = new FormAttachment(100, 0);
+    fdSash.top = new FormAttachment(toolbar, margin);
+    fdSash.bottom = new FormAttachment(wbOpen, -margin);
+    sash.setLayoutData(fdSash);
+
+    wTree =
+        new Tree(sash, SWT.SINGLE | SWT.V_SCROLL | SWT.H_SCROLL | SWT.FULL_SELECTION | SWT.BORDER);
+    PropsUi.setLook(wTree);
+    wTree.setHeaderVisible(true);
+    TreeColumn nameColumn = new TreeColumn(wTree, SWT.LEFT);
+    nameColumn.setText(BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Column.Name"));
+    nameColumn.setWidth((int) (340 * props.getZoomFactor()));
+    TreeColumn matchColumn = new TreeColumn(wTree, SWT.LEFT);
+    matchColumn.setText(BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Column.Match"));
+    matchColumn.setWidth((int) (320 * props.getZoomFactor()));
+    wTree.addListener(SWT.Selection, e -> treeSelectionChanged());
+    wTree.addListener(SWT.DefaultSelection, this::open);
+
+    // The last column (Matched text) always fills the remaining width.
+    wTree.addListener(SWT.Resize, e -> fillLastColumn());
+    nameColumn.addListener(SWT.Resize, e -> fillLastColumn());
+
+    buildDetailsPanel(sash);
+
+    restoreSashWeights();
+  }
+
+  private void buildDetailsPanel(Composite parent) {
+    Composite details = new Composite(parent, SWT.NONE);
+    PropsUi.setLook(details);
+    details.setLayout(new GridLayout(2, false));
+
+    wdType = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.Type");
+    wdName = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.Name");
+    wdFile = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.File");
+    wdLocation = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.Location");
+    wdMatch = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.Match");
+    wdValue = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.Value");
+    wdDescription = addDetailRow(details, "HopGuiSearchResultsPanel.Detail.Description");
+  }
+
+  private Label addDetailRow(Composite details, String captionKey) {
+    Label caption = new Label(details, SWT.LEFT);
+    PropsUi.setLook(caption);
+    caption.setText(BaseMessages.getString(PKG, captionKey) + ":");
+    caption.setFont(GuiResource.getInstance().getFontBold());
+    caption.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false));
+
+    Label value = new Label(details, SWT.LEFT | SWT.WRAP);
+    PropsUi.setLook(value);
+    value.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false));
+    return value;
+  }
+
+  /**
+   * Pre-fill the search field with the given query and run it immediately. Used by the global
+   * search popup to hand a query off for deep browsing ("Show all results").
+   */
+  public void setSearchQuery(String searchString, boolean caseSensitive, boolean regEx) {
+    if (wSearchString == null || wSearchString.isDisposed()) {
+      return;
+    }
+    wSearchString.setText(Const.NVL(searchString, ""));
+    wCaseSensitive.setSelection(caseSensitive);
+    wRegEx.setSelection(regEx);
+    search(new Event());
+  }
+
+  /** Refresh searchable locations + history and re-enumerate; call when this panel is (re)shown. */
+  public void prepareForActivation() {
+    searchablesLocations = hopGui.getSearchablesLocations();
+    refreshLastUsedSearchStrings();
+    invalidateCache();
+    focusSearchField();
+  }
+
+  public void focusSearchField() {
+    if (wSearchString != null && !wSearchString.isDisposed()) {
+      wSearchString.setFocus();
+    }
+  }
+
+  private void refreshLastUsedSearchStrings() {
+    if (wSearchString != null && !wSearchString.isDisposed()) {
+      try {
+        String[] lastUsedValues =
+            AuditManagerGuiUtil.getLastUsedValues(HopGuiSearchHelper.AUDIT_TYPE_SEARCH_STRING);
+        // Keep what the user is typing; only refresh the dropdown history.
+        String current = wSearchString.getText();
+        wSearchString.setItems(lastUsedValues);
+        wSearchString.setText(current);
+        wSearchString.setSelection(new Point(current.length(), current.length()));
+      } catch (Exception e) {
+        hopGui.getLog().logError("Error reading list of used search strings", e);
+      }
+    }
+  }
+
+  private void scheduleSearch() {
+    if (isDisposed()) {
+      return;
+    }
+    getDisplay().timerExec(-1, searchRunnable);
+    getDisplay().timerExec(SEARCH_DELAY_MS, searchRunnable);
+  }
+
+  /** Size the last column (Matched text) to take whatever width is left after the Name column. */
+  private void fillLastColumn() {
+    if (wTree == null || wTree.isDisposed() || wTree.getColumnCount() < 2) {
+      return;
+    }
+    int width = wTree.getClientArea().width;
+    if (width <= 0) {
+      return;
+    }
+    TreeColumn[] columns = wTree.getColumns();
+    columns[1].setWidth(Math.max(150, width - columns[0].getWidth()));
+  }
+
+  /** Restore the remembered sash weights, using the same audit-state mechanism as perspectives. */
+  private void restoreSashWeights() {
+    int[] weights = DEFAULT_SASH_WEIGHTS;
+    try {
+      AuditStateMap stateMap =
+          AuditManager.getActive()
+              .loadAuditStateMap(HopNamespace.getNamespace(), SEARCH_AUDIT_TYPE);
+      AuditState state = stateMap.get(STATE_SASH_WEIGHTS_KEY);
+      if (state != null) {
+        Object value = state.getStateMap().get(STATE_SASH_WEIGHTS_PROP);
+        if (value != null) {
+          String[] parts = value.toString().split(",");
+          if (parts.length >= 2) {
+            weights =
+                new int[] {Integer.parseInt(parts[0].trim()), Integer.parseInt(parts[1].trim())};
+          }
+        }
+      }
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error restoring search results sash weights", e);
+    }
+    sash.setWeights(weights);
+  }
+
+  /** Persist the sash weights. */
+  public void saveState() {
+    if (sash == null || sash.isDisposed()) {
+      return;
+    }
+    try {
+      int[] weights = sash.getWeights();
+      if (weights == null || weights.length < 2) {
+        return;
+      }
+      AuditStateMap stateMap = new AuditStateMap();
+      stateMap.add(
+          new AuditState(
+              STATE_SASH_WEIGHTS_KEY,
+              Map.of(STATE_SASH_WEIGHTS_PROP, weights[0] + "," + weights[1])));
+      AuditManager.getActive()
+          .saveAuditStateMap(HopNamespace.getNamespace(), SEARCH_AUDIT_TYPE, stateMap);
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error saving search results sash weights", e);
+    }
+  }
+
+  public void clearSearchFilters() {
+    invalidateCache();
+    if (wSearchString != null && !wSearchString.isDisposed()) {
+      wSearchString.setText("");
+    }
+    if (wTree != null && !wTree.isDisposed()) {
+      wTree.removeAll();
+    }
+    clearDetails();
+    if (wbOpen != null && !wbOpen.isDisposed()) {
+      wbOpen.setEnabled(false);
+    }
+  }
+
+  private void invalidateCache() {
+    cachedEnumeration = null;
+  }
+
+  /** Make sure the searchables across all locations are enumerated once (and cached). */
+  private boolean ensureLoaded() {
+    if (cachedEnumeration != null) {
+      return true;
+    }
+    try {
+      if (cachedAnalysers == null) {
+        cachedAnalysers = HopGuiSearchHelper.loadSearchableAnalysers();
+      }
+      cachedEnumeration =
+          HopGuiSearchHelper.enumerateAll(
+              searchablesLocations,
+              hopGui.getMetadataProvider(),
+              hopGui.getVariables(),
+              hopGui.getLog());
+      return true;
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error loading searchables", e);
+      invalidateCache();
+      return false;
+    }
+  }
+
+  private void search(Event event) {
+    if (wTree == null || wTree.isDisposed()) {
+      return;
+    }
+    wTree.removeAll();
+    clearDetails();
+    wbOpen.setEnabled(false);
+
+    String searchString = wSearchString.getText();
+    if (Utils.isEmpty(searchString)) {
+      return;
+    }
+
+    if (searchablesLocations == null) {
+      searchablesLocations = hopGui.getSearchablesLocations();
+    }
+    if (!ensureLoaded()) {
+      return;
+    }
+
+    SearchQuery query =
+        new SearchQuery(searchString, wCaseSensitive.getSelection(), wRegEx.getSelection());
+
+    // The searchables were enumerated once; here we only run the cheap in-memory matching.
+    // Errors are logged (not shown in a modal dialog) since this runs on every debounced keystroke.
+    List<ISearchResult> results;
+    try {
+      results =
+          HopGuiSearchHelper.analyseRanked(
+              cachedEnumeration.getSearchables(), query, cachedAnalysers, true);
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error while searching", e);
+      results = new ArrayList<>();
+    }
+    populateTree(results);
+  }
+
+  /**
+   * Render the two-tier tree: section (Open/Project) -> type -> object -> the matches inside it.
+   */
+  private void populateTree(List<ISearchResult> results) {
+    String searchTerm = wRegEx.getSelection() ? null : wSearchString.getText();
+
+    TreeItem firstLeaf = null;
+    for (HopGuiSearchHelper.SearchSection section :
+        HopGuiSearchHelper.groupResults(results, cachedEnumeration.getSourceByKey())) {
+      TreeItem sectionItem = new TreeItem(wTree, SWT.NONE);
+      sectionItem.setText(
+          0, sectionLabel(section.getKey()) + "  (" + section.getObjectCount() + ")");
+      sectionItem.setFont(GuiResource.getInstance().getFontBold());
+
+      for (HopGuiSearchHelper.SearchTypeGroup typeGroup : section.getTypeGroups()) {
+        TreeItem typeItem = new TreeItem(sectionItem, SWT.NONE);
+        typeItem.setText(0, typeGroup.getType() + "  (" + typeGroup.getObjects().size() + ")");
+        typeItem.setFont(GuiResource.getInstance().getFontBold());
+
+        for (HopGuiSearchHelper.SearchObjectGroup group : typeGroup.getObjects()) {
+          TreeItem objectItem = new TreeItem(typeItem, SWT.NONE);
+          objectItem.setText(
+              0, HopGuiSearchHelper.displayLabel(group.getSearchable(), hopGui.getVariables()));
+          objectItem.setData(group.getPrimary());
+          if (firstLeaf == null) {
+            firstLeaf = objectItem;
+          }
+          for (ISearchResult match : group.getMatches()) {
+            TreeItem matchItem = new TreeItem(objectItem, SWT.NONE);
+            matchItem.setText(0, HopGuiSearchHelper.matchWhere(match));
+            matchItem.setText(1, HopGuiSearchHelper.matchSnippet(match, searchTerm));
+            matchItem.setData(match);
+          }
+          objectItem.setExpanded(true);
+        }
+        typeItem.setExpanded(true);
+      }
+      sectionItem.setExpanded(true);
+    }
+
+    if (firstLeaf != null) {
+      wTree.setSelection(firstLeaf);
+      treeSelectionChanged();
+    }
+  }
+
+  private String sectionLabel(String sectionKey) {
+    if (HopGuiSearchHelper.SECTION_OPEN.equals(sectionKey)) {
+      return BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Section.Open");
+    }
+    return BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Section.Project");
+  }
+
+  private void treeSelectionChanged() {
+    TreeItem[] selection = wTree.getSelection();
+    boolean isResult = selection.length > 0 && selection[0].getData() instanceof ISearchResult;
+    wbOpen.setEnabled(isResult);
+    if (isResult) {
+      showDetails((ISearchResult) selection[0].getData());
+    } else {
+      clearDetails();
+    }
+  }
+
+  private void showDetails(ISearchResult result) {
+    ISearchable searchable = result.getMatchingSearchable();
+    wdType.setText(Const.NVL(searchable.getType(), ""));
+    wdName.setText(Const.NVL(searchable.getName(), ""));
+    wdFile.setText(
+        HopGuiSearchHelper.sanitizePath(searchable.getFilename(), hopGui.getVariables()));
+    wdLocation.setText(Const.NVL(searchable.getLocation(), ""));
+    wdMatch.setText(Const.NVL(result.getMatchingString(), ""));
+    wdValue.setText(Const.NVL(result.getValue(), ""));
+    wdDescription.setText(Const.NVL(Const.NVL(result.getComponent(), result.getDescription()), ""));
+    wdType.getParent().layout();
+  }
+
+  private void clearDetails() {
+    for (Label label :
+        new Label[] {wdType, wdName, wdFile, wdLocation, wdMatch, wdValue, wdDescription}) {
+      if (label != null && !label.isDisposed()) {
+        label.setText("");
+      }
+    }
+  }
+
+  private void persistSearchString() {
+    if (wSearchString != null && !wSearchString.isDisposed()) {
+      String text = wSearchString.getText();
+      if (!Utils.isEmpty(text)) {
+        AuditManagerGuiUtil.addLastUsedValue(HopGuiSearchHelper.AUDIT_TYPE_SEARCH_STRING, text);
+      }
+    }
+  }
+
+  private void open(Event event) {
+    TreeItem[] selection = wTree.getSelection();
+    if (selection.length == 0 || !(selection[0].getData() instanceof ISearchResult)) {
+      return;
+    }
+    persistSearchString();
+
+    ISearchResult searchResult = (ISearchResult) selection[0].getData();
+    ISearchable searchable = searchResult.getMatchingSearchable();
+    try {
+      searchable.getSearchCallback().callback(searchable, searchResult);
+    } catch (Exception e) {
+      new ErrorDialog(
+          hopGui.getActiveShell(),
+          BaseMessages.getString(PKG, "HopGuiSearchResultsPanel.Error.Open.Title"),
+          BaseMessages.getString(
+              PKG,
+              "HopGuiSearchResultsPanel.Error.Open.Message",
+              Const.NVL(searchable.getName(), "")),
+          e);
+    }
+  }
+}
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
new file mode 100644
index 0000000000..1f17de57c6
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
@@ -0,0 +1,672 @@
+/*
+ * 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.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+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.SearchQuery;
+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.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiMenuWidgets;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
+import org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+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.Display;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Link;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeColumn;
+import org.eclipse.swt.widgets.TreeItem;
+
+/**
+ * IntelliJ-style "Search Everywhere" popup. It runs the same searchable-analyser 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.
+ */
+public class SearchEverywhereDialog {
+
+  private static final Class<?> PKG = SearchEverywhereDialog.class; // i18n
+
+  /** Debounce in ms between a keystroke and running the (potentially expensive) search. */
+  private static final int SEARCH_DELAY_MS = 250;
+
+  /**
+   * Maximum number of results shown per group in the popup; the full set lives in the perspective.
+   */
+  private static final int MAX_PER_GROUP = 50;
+
+  private static final String ACTIONS_GROUP = "Actions";
+
+  /** Minimum size of the resizable popup. */
+  private static final int MIN_WIDTH = 420;
+
+  private static final int MIN_HEIGHT = 280;
+
+  private final HopGui hopGui;
+  private final Shell parent;
+  private final PropsUi props;
+
+  /** Searchables enumerated once when the popup opens and reused for every keystroke. */
+  private List<ISearchable> cachedSearchables;
+
+  private Map<Class<ISearchableAnalyser>, ISearchableAnalyser> cachedAnalysers;
+
+  /** For each cached searchable (by dedup key) the index of the location it came from (0 = GUI). */
+  private final Map<String, Integer> sourceByKey = new HashMap<>();
+
+  private Shell shell;
+  private Combo wSearch;
+  private Button wCaseSensitive;
+  private Button wRegEx;
+  private Tree wTree;
+  private TreeColumn nameColumn;
+  private TreeColumn detailColumn;
+  private Link wShowAll;
+
+  private Color detailColor;
+  private final Runnable searchRunnable = this::runSearch;
+  private int fullResultCount;
+
+  public SearchEverywhereDialog(Shell parent, HopGui hopGui) {
+    this.parent = parent;
+    this.hopGui = hopGui;
+    this.props = PropsUi.getInstance();
+  }
+
+  public void open() {
+    Display display = parent.getDisplay();
+    detailColor = display.getSystemColor(SWT.COLOR_DARK_GRAY);
+
+    shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE);
+    shell.setText(BaseMessages.getString(PKG, "SearchEverywhereDialog.Shell.Title"));
+    shell.setImage(GuiResource.getInstance().getImageHop());
+    PropsUi.setLook(shell);
+    FormLayout formLayout = new FormLayout();
+    formLayout.marginWidth = PropsUi.getMargin();
+    formLayout.marginHeight = PropsUi.getMargin();
+    shell.setLayout(formLayout);
+
+    int margin = PropsUi.getMargin();
+
+    // --- Search bar: text field + case / regex toggles ---
+    Composite searchBar = new Composite(shell, SWT.NONE);
+    PropsUi.setLook(searchBar);
+    searchBar.setLayout(new GridLayout(3, false));
+    FormData fdSearchBar = new FormData();
+    fdSearchBar.top = new FormAttachment(0, 0);
+    fdSearchBar.left = new FormAttachment(0, 0);
+    fdSearchBar.right = new FormAttachment(100, 0);
+    searchBar.setLayoutData(fdSearchBar);
+
+    wSearch = new Combo(searchBar, SWT.SINGLE | SWT.BORDER | SWT.DROP_DOWN);
+    wSearch.setToolTipText(
+        BaseMessages.getString(PKG, "SearchEverywhereDialog.Search.Placeholder"));
+    wSearch.setFont(GuiResource.getInstance().getFontBold());
+    wSearch.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+    PropsUi.setLook(wSearch);
+    loadHistory();
+
+    wCaseSensitive = new Button(searchBar, SWT.CHECK);
+    wCaseSensitive.setText(
+        BaseMessages.getString(PKG, "SearchEverywhereDialog.CaseSensitive.Label"));
+    wCaseSensitive.setToolTipText(
+        BaseMessages.getString(PKG, "SearchEverywhereDialog.CaseSensitive.Tooltip"));
+    PropsUi.setLook(wCaseSensitive);
+
+    wRegEx = new Button(searchBar, SWT.CHECK);
+    wRegEx.setText(BaseMessages.getString(PKG, "SearchEverywhereDialog.RegEx.Label"));
+    wRegEx.setToolTipText(BaseMessages.getString(PKG, "SearchEverywhereDialog.RegEx.Tooltip"));
+    PropsUi.setLook(wRegEx);
+
+    // --- Footer: hint + "show all" handoff to the search perspective ---
+    wShowAll = new Link(shell, 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.addListener(SWT.Selection, e -> openShowAll());
+
+    // --- The grouped result tree (two user-resizable columns + horizontal scroll) ---
+    wTree = new Tree(shell, SWT.SINGLE | SWT.FULL_SELECTION | SWT.V_SCROLL | SWT.H_SCROLL);
+    PropsUi.setLook(wTree);
+    wTree.setHeaderVisible(true);
+    nameColumn = new TreeColumn(wTree, SWT.LEFT);
+    nameColumn.setText(BaseMessages.getString(PKG, "SearchEverywhereDialog.Column.Name"));
+    nameColumn.setResizable(true);
+    detailColumn = new TreeColumn(wTree, SWT.LEFT);
+    detailColumn.setText(BaseMessages.getString(PKG, "SearchEverywhereDialog.Column.Location"));
+    detailColumn.setResizable(true);
+    FormData fdTree = new FormData();
+    fdTree.top = new FormAttachment(searchBar, margin);
+    fdTree.left = new FormAttachment(0, 0);
+    fdTree.right = new FormAttachment(100, 0);
+    fdTree.bottom = new FormAttachment(wShowAll, -margin);
+    wTree.setLayoutData(fdTree);
+
+    // The Location column always fills the remaining width (also when the window or the Name column
+    // is resized).
+    wTree.addListener(SWT.Resize, e -> fillLocationColumn());
+    nameColumn.addListener(SWT.Resize, e -> fillLocationColumn());
+
+    // Open a result on double-click.
+    wTree.addListener(SWT.MouseDoubleClick, e -> openSelected());
+
+    // All keyboard handling lives on the search field so focus never leaves it.
+    wSearch.addListener(SWT.Modify, e -> scheduleSearch());
+    wSearch.addListener(SWT.KeyDown, this::handleSearchKey);
+
+    // Close on Escape or via the window close button (both persist the window size).
+    shell.addListener(
+        SWT.Traverse,
+        e -> {
+          if (e.detail == SWT.TRAVERSE_ESCAPE) {
+            e.doit = false;
+            dispose();
+          }
+        });
+    shell.addListener(
+        SWT.Close,
+        e -> {
+          e.doit = false;
+          dispose();
+        });
+
+    updateShowAll();
+    restoreSize();
+
+    shell.open();
+    wSearch.setFocus();
+    initColumnWidths();
+
+    while (!shell.isDisposed()) {
+      if (!display.readAndDispatch()) {
+        display.sleep();
+      }
+    }
+  }
+
+  /** Restore the previously remembered size/position, or center a sensible default. */
+  private void restoreSize() {
+    shell.setMinimumSize(MIN_WIDTH, MIN_HEIGHT);
+    WindowProperty windowProperty = props.getScreen(shell.getText());
+    if (windowProperty != null) {
+      windowProperty.setShell(shell, MIN_WIDTH, MIN_HEIGHT);
+    } else {
+      sizeAndCenter();
+    }
+  }
+
+  private void sizeAndCenter() {
+    Rectangle parentBounds = parent.getBounds();
+    int width = Math.min((int) (720 * props.getZoomFactor()), parentBounds.width - 100);
+    int height = Math.min((int) (460 * props.getZoomFactor()), parentBounds.height - 120);
+    width = Math.max(width, MIN_WIDTH);
+    height = Math.max(height, MIN_HEIGHT);
+    int x = parentBounds.x + (parentBounds.width - width) / 2;
+    int y = parentBounds.y + Math.max(60, (parentBounds.height - height) / 3);
+    shell.setBounds(x, y, width, height);
+  }
+
+  /**
+   * Give the Name column a compact starting width; the Location column then fills the rest. The
+   * user can still drag the Name column wider, and the Location column keeps filling the remaining
+   * space.
+   */
+  private void initColumnWidths() {
+    int width = treeWidth();
+    if (width <= 0) {
+      return;
+    }
+    nameColumn.setWidth((int) (width * 0.30));
+    fillLocationColumn();
+  }
+
+  /** Size the Location column to take up whatever width is left after the Name column. */
+  private void fillLocationColumn() {
+    if (wTree == null || wTree.isDisposed()) {
+      return;
+    }
+    int width = treeWidth();
+    if (width <= 0) {
+      return;
+    }
+    detailColumn.setWidth(Math.max(120, width - nameColumn.getWidth()));
+  }
+
+  private int treeWidth() {
+    int width = wTree.getClientArea().width;
+    if (width <= 0 && shell != null && !shell.isDisposed()) {
+      width = shell.getClientArea().width;
+    }
+    return width;
+  }
+
+  private void scheduleSearch() {
+    Display display = shell.getDisplay();
+    display.timerExec(-1, searchRunnable);
+    display.timerExec(SEARCH_DELAY_MS, searchRunnable);
+  }
+
+  private void handleSearchKey(Event event) {
+    switch (event.keyCode) {
+      case SWT.ARROW_DOWN:
+        event.doit = false;
+        moveSelection(1);
+        break;
+      case SWT.ARROW_UP:
+        event.doit = false;
+        moveSelection(-1);
+        break;
+      case SWT.CR:
+      case SWT.KEYPAD_CR:
+        event.doit = false;
+        openSelected();
+        break;
+      default:
+        // let the keystroke reach the text field
+        break;
+    }
+  }
+
+  /**
+   * All selectable items in visual order: the object nodes and their match children (and action
+   * items), skipping the section/type header rows (which carry no data). Used for keyboard
+   * navigation.
+   */
+  private List<TreeItem> leaves() {
+    List<TreeItem> leaves = new ArrayList<>();
+    for (TreeItem item : wTree.getItems()) {
+      collectSelectable(item, leaves);
+    }
+    return leaves;
+  }
+
+  private void collectSelectable(TreeItem item, List<TreeItem> leaves) {
+    if (item.getData() != null) {
+      leaves.add(item);
+    }
+    for (TreeItem child : item.getItems()) {
+      collectSelectable(child, leaves);
+    }
+  }
+
+  private void moveSelection(int delta) {
+    List<TreeItem> leaves = leaves();
+    if (leaves.isEmpty()) {
+      return;
+    }
+    int index = -1;
+    TreeItem[] selection = wTree.getSelection();
+    if (selection.length > 0) {
+      index = leaves.indexOf(selection[0]);
+    }
+    index += delta;
+    if (index < 0) {
+      index = 0;
+    }
+    if (index >= leaves.size()) {
+      index = leaves.size() - 1;
+    }
+    TreeItem item = leaves.get(index);
+    wTree.setSelection(item);
+    wTree.showItem(item);
+  }
+
+  /**
+   * Enumerate all searchables once (loading pipelines/workflows/metadata from disk) and cache them,
+   * 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 void ensureLoaded() {
+    if (cachedSearchables != null) {
+      return;
+    }
+    try {
+      cachedAnalysers = HopGuiSearchHelper.loadSearchableAnalysers();
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error loading search analysers", e);
+      cachedAnalysers = new HashMap<>();
+    }
+    HopGuiSearchHelper.EnumeratedSearchables enumerated =
+        HopGuiSearchHelper.enumerateAll(
+            hopGui.getSearchablesLocations(),
+            hopGui.getMetadataProvider(),
+            hopGui.getVariables(),
+            hopGui.getLog());
+    cachedSearchables = enumerated.getSearchables();
+    sourceByKey.clear();
+    sourceByKey.putAll(enumerated.getSourceByKey());
+  }
+
+  private void runSearch() {
+    if (shell == null || shell.isDisposed()) {
+      return;
+    }
+    wTree.removeAll();
+    fullResultCount = 0;
+
+    String searchString = wSearch.getText();
+    if (Utils.isEmpty(searchString)) {
+      updateShowAll();
+      return;
+    }
+
+    ensureLoaded();
+
+    boolean caseSensitive = wCaseSensitive.getSelection();
+    boolean regExp = wRegEx.getSelection();
+    SearchQuery query = new SearchQuery(searchString, caseSensitive, regExp);
+
+    // The searchables were enumerated once when the popup opened; here we only run the cheap
+    // in-memory analyser matching against that cached set. Errors are logged (not shown in a modal
+    // dialog) since this runs on every debounced keystroke.
+    List<ISearchResult> results;
+    try {
+      results = HopGuiSearchHelper.analyseRanked(cachedSearchables, query, cachedAnalysers, true);
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error while searching", e);
+      results = new ArrayList<>();
+    }
+
+    // Render the two-tier tree: section (Open/Project) -> type -> object -> the matches inside it.
+    String searchTerm = regExp ? null : searchString;
+    for (HopGuiSearchHelper.SearchSection section :
+        HopGuiSearchHelper.groupResults(results, sourceByKey)) {
+      addSection(section, searchTerm);
+    }
+    addCommandGroup(searchString, caseSensitive);
+
+    selectFirstLeaf();
+    updateShowAll();
+  }
+
+  private void addSection(HopGuiSearchHelper.SearchSection section, String searchTerm) {
+    fullResultCount += section.getObjectCount();
+    TreeItem sectionItem = new TreeItem(wTree, SWT.NONE);
+    sectionItem.setText(
+        0, groupLabel(displaySectionLabel(section.getKey()), section.getObjectCount()));
+    sectionItem.setFont(GuiResource.getInstance().getFontBold());
+
+    for (HopGuiSearchHelper.SearchTypeGroup typeGroup : section.getTypeGroups()) {
+      TreeItem typeItem = new TreeItem(sectionItem, SWT.NONE);
+      typeItem.setText(0, groupLabel(typeGroup.getType(), typeGroup.getObjects().size()));
+      typeItem.setFont(GuiResource.getInstance().getFontBold());
+
+      int shown = Math.min(typeGroup.getObjects().size(), MAX_PER_GROUP);
+      for (int i = 0; i < shown; i++) {
+        addObject(typeItem, typeGroup.getObjects().get(i), searchTerm);
+      }
+      typeItem.setExpanded(true);
+    }
+    sectionItem.setExpanded(true);
+  }
+
+  /** Add an object node (col 0 = name, col 1 = location) and a child row for each inner match. */
+  private void addObject(
+      TreeItem parent, HopGuiSearchHelper.SearchObjectGroup group, String searchTerm) {
+    ISearchable searchable = group.getSearchable();
+    TreeItem objectItem = new TreeItem(parent, SWT.NONE);
+    objectItem.setText(0, HopGuiSearchHelper.displayLabel(searchable, hopGui.getVariables()));
+    objectItem.setText(1, objectLocation(searchable));
+    objectItem.setForeground(1, detailColor);
+    objectItem.setData(group.getPrimary());
+
+    for (ISearchResult match : group.getMatches()) {
+      TreeItem matchItem = new TreeItem(objectItem, SWT.NONE);
+      matchItem.setText(0, HopGuiSearchHelper.matchWhere(match));
+      matchItem.setText(1, HopGuiSearchHelper.matchSnippet(match, searchTerm));
+      matchItem.setForeground(1, detailColor);
+      matchItem.setData(match);
+    }
+    objectItem.setExpanded(true);
+  }
+
+  private String displaySectionLabel(String sectionKey) {
+    if (HopGuiSearchHelper.SECTION_OPEN.equals(sectionKey)) {
+      return BaseMessages.getString(PKG, "SearchEverywhereDialog.Group.Open");
+    }
+    return BaseMessages.getString(PKG, "SearchEverywhereDialog.Group.Project");
+  }
+
+  /** The file path (sanitized to {@code ${PROJECT_HOME}}) or location shown for an object node. */
+  private String objectLocation(ISearchable searchable) {
+    String detail = searchable.getFilename();
+    return Utils.isEmpty(detail)
+        ? Const.NVL(searchable.getLocation(), "")
+        : HopGuiSearchHelper.sanitizePath(detail, hopGui.getVariables());
+  }
+
+  private void addCommandGroup(String searchString, boolean caseSensitive) {
+    GuiMenuWidgets menuWidgets = hopGui.getMainMenuWidgets();
+    if (menuWidgets == null) {
+      return;
+    }
+    String needle = caseSensitive ? searchString : searchString.toLowerCase(Locale.ROOT);
+
+    List<MenuItem> matches = new ArrayList<>();
+    for (MenuItem menuItem : menuWidgets.getMenuItemMap().values()) {
+      if (menuItem == null || menuItem.isDisposed() || !menuItem.isEnabled()) {
+        continue;
+      }
+      int style = menuItem.getStyle();
+      if ((style & (SWT.CASCADE | SWT.SEPARATOR | SWT.CHECK | SWT.RADIO)) != 0) {
+        continue;
+      }
+      String label = commandLabel(menuItem);
+      if (Utils.isEmpty(label)) {
+        continue;
+      }
+      String haystack = caseSensitive ? label : label.toLowerCase(Locale.ROOT);
+      if (haystack.contains(needle)) {
+        matches.add(menuItem);
+      }
+    }
+    if (matches.isEmpty()) {
+      return;
+    }
+
+    fullResultCount += matches.size();
+    TreeItem group = new TreeItem(wTree, SWT.NONE);
+    group.setText(groupLabel(ACTIONS_GROUP, matches.size()));
+    group.setFont(GuiResource.getInstance().getFontBold());
+    int shown = Math.min(matches.size(), MAX_PER_GROUP);
+    for (int i = 0; i < shown; i++) {
+      MenuItem menuItem = matches.get(i);
+      TreeItem item = new TreeItem(group, SWT.NONE);
+      item.setText(0, commandLabel(menuItem));
+      Image image = menuItem.getImage();
+      if (image != null) {
+        item.setImage(0, image);
+      }
+      item.setData(menuItem);
+    }
+    group.setExpanded(true);
+  }
+
+  /** Strip the accelerator suffix and mnemonic markers from a menu item label. */
+  private static String commandLabel(MenuItem menuItem) {
+    String label = menuItem.getText();
+    if (label == null) {
+      return "";
+    }
+    int tab = label.indexOf('\t');
+    if (tab >= 0) {
+      label = label.substring(0, tab);
+    }
+    return label.replace("&", "").trim();
+  }
+
+  private static String groupLabel(String type, int count) {
+    String name = Utils.isEmpty(type) ? "?" : type;
+    return name + "  (" + count + ")";
+  }
+
+  private void selectFirstLeaf() {
+    List<TreeItem> leaves = leaves();
+    if (!leaves.isEmpty()) {
+      wTree.setSelection(leaves.get(0));
+    }
+  }
+
+  private void updateShowAll() {
+    if (fullResultCount > 0) {
+      wShowAll.setText(
+          BaseMessages.getString(
+              PKG, "SearchEverywhereDialog.ShowAll.Label", Integer.toString(fullResultCount)));
+    } else {
+      wShowAll.setText(BaseMessages.getString(PKG, "SearchEverywhereDialog.Hint.Label"));
+    }
+    wShowAll.requestLayout();
+  }
+
+  private void openSelected() {
+    TreeItem[] selection = wTree.getSelection();
+    if (selection.length == 0) {
+      return;
+    }
+    Object data = selection[0].getData();
+    if (data == null) {
+      // A group header was selected; do nothing.
+      return;
+    }
+    if (data instanceof ISearchResult result) {
+      openResult(result);
+    } else if (data instanceof MenuItem menuItem) {
+      persistHistory();
+      dispose();
+      if (!menuItem.isDisposed()) {
+        menuItem.notifyListeners(SWT.Selection, new Event());
+      }
+    }
+  }
+
+  private void openResult(ISearchResult result) {
+    ISearchable searchable = result.getMatchingSearchable();
+    persistHistory();
+    dispose();
+    try {
+      searchable.getSearchCallback().callback(searchable, result);
+    } catch (Exception e) {
+      new ErrorDialog(
+          hopGui.getActiveShell(),
+          BaseMessages.getString(PKG, "SearchEverywhereDialog.Error.Title"),
+          BaseMessages.getString(
+              PKG,
+              "SearchEverywhereDialog.Error.Open.Message",
+              Const.NVL(searchable.getName(), "")),
+          e);
+    }
+  }
+
+  /**
+   * Hand the current query off for deep browsing into a Search tab in the bottom dock. Each "Show
+   * all" opens a new tab so several searches can be kept side by side.
+   */
+  private void openShowAll() {
+    String searchString = wSearch.getText();
+    boolean caseSensitive = wCaseSensitive.getSelection();
+    boolean regExp = wRegEx.getSelection();
+    persistHistory();
+    dispose();
+
+    HopGuiBottomDock dock = hopGui.getTerminalPanel();
+    if (dock == null || dock.isDisposed()) {
+      return;
+    }
+    Control content =
+        dock.openToolTab(
+            searchTabTitle(searchString),
+            GuiResource.getInstance().getImage("ui/images/search.svg", 16, 16),
+            true,
+            container -> new HopGuiSearchResultsPanel(container, hopGui));
+    if (content instanceof HopGuiSearchResultsPanel panel) {
+      panel.setSearchQuery(searchString, caseSensitive, regExp);
+      panel.focusSearchField();
+    }
+  }
+
+  /** A dock tab title for a search: the query itself (truncated), so multiple tabs are tellable. */
+  private String searchTabTitle(String query) {
+    String title = query == null ? "" : query.trim();
+    if (title.isEmpty()) {
+      return BaseMessages.getString(PKG, "SearchEverywhereDialog.ShowAll.TabTitle");
+    }
+    if (title.length() > 30) {
+      title = title.substring(0, 29) + "…";
+    }
+    return title;
+  }
+
+  /** Load the shared search history (also used by the search perspective) into the combo. */
+  private void loadHistory() {
+    try {
+      wSearch.setItems(
+          AuditManagerGuiUtil.getLastUsedValues(HopGuiSearchHelper.AUDIT_TYPE_SEARCH_STRING));
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error reading search history", e);
+    }
+  }
+
+  /** Remember the current search string in the shared history when a result is actually used. */
+  private void persistHistory() {
+    if (wSearch != null && !wSearch.isDisposed()) {
+      String text = wSearch.getText();
+      if (!Utils.isEmpty(text)) {
+        AuditManagerGuiUtil.addLastUsedValue(HopGuiSearchHelper.AUDIT_TYPE_SEARCH_STRING, text);
+      }
+    }
+  }
+
+  private void dispose() {
+    if (shell != null && !shell.isDisposed()) {
+      // Remember the size and position for next time.
+      props.setScreen(new WindowProperty(shell));
+      shell.dispose();
+    }
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
similarity index 76%
rename from ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
rename to ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
index c7c3be790b..1c50574deb 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDock.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.ui.hopgui.terminal;
 
+import java.util.function.Function;
 import lombok.Getter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
@@ -44,27 +45,32 @@ import org.eclipse.swt.custom.CTabFolder2Adapter;
 import org.eclipse.swt.custom.CTabFolderEvent;
 import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.graphics.Image;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Text;
 import org.eclipse.swt.widgets.ToolBar;
 import org.eclipse.swt.widgets.ToolItem;
 
 /**
- * Terminal panel for Hop GUI providing integrated command-line access.
- *
- * <p>The panel wraps the main perspectives composite in a SashForm, with perspectives in the top
- * section and the terminal panel in the bottom section. The terminal panel persists across
+ * Bottom dock for Hop GUI. It wraps the main perspectives composite in a vertical SashForm, with
+ * the perspectives in the top section and a tabbed dock in the bottom section that persists across
  * perspective switches.
+ *
+ * <p>The dock hosts two kinds of tabs in one {@link CTabFolder}: terminal tabs (the integrated
+ * command line, a gated capability - see {@link #terminalsEnabled}) and generic "tool" tabs opened
+ * through {@link #focusOrOpenToolTab} (e.g. the search results view). Terminal-specific behaviour
+ * (the "+" tab, font sizing, save/restore) only applies to terminal tabs.
  */
 @GuiPlugin(name = "Terminal panel", description = "Terminal panel")
-public class HopGuiTerminalPanel extends Composite implements TabClosable {
+public class HopGuiBottomDock extends Composite implements TabClosable {
 
-  private static final Class<?> PKG = HopGuiTerminalPanel.class;
+  private static final Class<?> PKG = HopGuiBottomDock.class;
 
   public static final String ID_MAIN_MENU_TOOLS_TERMINAL = "40010-menu-tools-terminal";
   public static final String ID_MAIN_MENU_TOOLS_NEW_TERMINAL = "40020-menu-tools-new-terminal";
@@ -82,6 +88,14 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
   private boolean isClearing = false;
   private int terminalCounter = 1;
 
+  /**
+   * Whether the terminal capability is available (turned off on web or via disabledGuiElements).
+   */
+  @Getter private boolean terminalsEnabled = true;
+
+  /** Counter used to give each non-singleton tool tab (e.g. a search result tab) a unique id. */
+  private int toolTabCounter = 1;
+
   /** Font size scale for all terminal tabs (100 = 100%). Persisted and applied to new tabs. */
   private int terminalFontSizePercent = 100;
 
@@ -100,15 +114,23 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
   private static final String STATE_SHELL_PATH = "shellPath";
   private static final String STATE_WORKING_DIR = "workingDirectory";
 
+  /** Tab data keys marking a non-terminal "tool" tab (e.g. search results) and its content. */
+  private static final String DATA_TOOL_ID = "dockToolId";
+
+  private static final String DATA_TOOL_CONTENT = "dockToolContent";
+
   /**
-   * Constructor - Creates the terminal panel structure
+   * Constructor - Creates the bottom dock structure
    *
    * @param parent The parent composite (mainHopGuiComposite from HopGui)
    * @param hopGui The HopGui instance
+   * @param terminalsEnabled whether the integrated terminal capability is available (off on web or
+   *     when disabled via disabledGuiElements.xml)
    */
-  public HopGuiTerminalPanel(Composite parent, HopGui hopGui) {
+  public HopGuiBottomDock(Composite parent, HopGui hopGui, boolean terminalsEnabled) {
     super(parent, SWT.NONE);
     this.hopGui = hopGui;
+    this.terminalsEnabled = terminalsEnabled;
 
     createContents();
   }
@@ -169,12 +191,15 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
 
     createTerminalToolbar();
 
-    newTerminalTab = new CTabItem(terminalTabs, SWT.NONE);
-    newTerminalTab.setText("+");
-    newTerminalTab.setToolTipText(
-        BaseMessages.getString(PKG, "HopGuiTerminalPanel.NewTab.Tooltip"));
-    Composite newTerminalPlaceholder = new Composite(terminalTabs, SWT.NONE);
-    newTerminalTab.setControl(newTerminalPlaceholder);
+    // The "+" (new terminal) tab only exists when the terminal capability is available.
+    if (terminalsEnabled) {
+      newTerminalTab = new CTabItem(terminalTabs, SWT.NONE);
+      newTerminalTab.setText("+");
+      newTerminalTab.setToolTipText(
+          BaseMessages.getString(PKG, "HopGuiTerminalPanel.NewTab.Tooltip"));
+      Composite newTerminalPlaceholder = new Composite(terminalTabs, SWT.NONE);
+      newTerminalTab.setControl(newTerminalPlaceholder);
+    }
 
     new TabCloseHandler(this);
     new TabFolderReorder(terminalTabs);
@@ -185,7 +210,11 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
         event -> {
           CTabItem item = terminalTabs.getSelection();
           // When only the "+" tab exists, getSelection() can be null; create a new terminal.
-          if (item == null && terminalTabs.getItemCount() == 1 && !isClearing && !isClosingTab[0]) {
+          if (terminalsEnabled
+              && item == null
+              && terminalTabs.getItemCount() == 1
+              && !isClearing
+              && !isClosingTab[0]) {
             createNewTerminal(null, null);
             return;
           }
@@ -196,7 +225,7 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
 
           if (item != null) {
             ITerminalWidget widget = (ITerminalWidget) item.getData("terminalWidget");
-            if (widget != null && widget instanceof JediTerminalWidget) {
+            if (widget != null) {
               Composite composite = widget.getTerminalComposite();
               if (composite != null && !composite.isDisposed()) {
                 composite.forceFocus();
@@ -210,6 +239,9 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
     terminalTabs.addListener(
         SWT.MouseDown,
         event -> {
+          if (!terminalsEnabled) {
+            return;
+          }
           CTabItem item = terminalTabs.getItem(new Point(event.x, event.y));
           if (item == newTerminalTab && !isClearing && !isClosingTab[0]) {
             createNewTerminal(null, null);
@@ -241,7 +273,8 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
         SWT.MouseDoubleClick,
         event -> {
           CTabItem item = terminalTabs.getSelection();
-          if (item != null && item != newTerminalTab) {
+          // Only terminal tabs can be renamed (not the "+" tab or generic tool tabs).
+          if (item != null && item != newTerminalTab && item.getData("terminalWidget") != null) {
             renameTerminalTab(item);
           }
         });
@@ -305,13 +338,10 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
               if (terminalWidget == null) {
                 return;
               }
-
-              if (terminalWidget instanceof JediTerminalWidget) {
-                Composite composite = terminalWidget.getTerminalComposite();
-                if (composite != null && !composite.isDisposed()) {
-                  composite.setFocus();
-                  composite.forceFocus();
-                }
+              Composite composite = terminalWidget.getTerminalComposite();
+              if (composite != null && !composite.isDisposed()) {
+                composite.setFocus();
+                composite.forceFocus();
               }
             });
   }
@@ -355,24 +385,27 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
     }
   }
 
-  /** Show the terminal panel */
-  public void showTerminal() {
+  /** Make the dock visible (without forcing a terminal to be created). */
+  public void showDock() {
     if (!terminalVisible) {
       verticalSash.setMaximizedControl(null);
       int perspectivePercent = 100 - terminalHeightPercent;
       verticalSash.setWeights(perspectivePercent, terminalHeightPercent);
       terminalVisible = true;
-
-      if (terminalTabs.getItemCount() <= 1) {
-        createNewTerminal(null, null);
-      }
-
       layout(true, true);
       hopGui.refreshSidebarToolbarButtonStates();
     }
   }
 
-  /** Hide the terminal panel */
+  /** Show the dock and make sure at least one terminal is present (the Ctrl+J behaviour). */
+  public void showTerminal() {
+    showDock();
+    if (terminalsEnabled && countTerminalTabs() == 0) {
+      createNewTerminal(null, null);
+    }
+  }
+
+  /** Hide the dock */
   public void hideTerminal() {
     if (terminalVisible) {
       terminalVisible = false;
@@ -382,6 +415,108 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
     }
   }
 
+  /** Number of real terminal tabs currently open (excludes the "+" tab and any tool tabs). */
+  private int countTerminalTabs() {
+    int count = 0;
+    for (CTabItem item : terminalTabs.getItems()) {
+      if (item != newTerminalTab && item.getData("terminalWidget") != null) {
+        count++;
+      }
+    }
+    return count;
+  }
+
+  // --- Generic tool tabs (non-terminal dock content) ---------------------------------------------
+
+  /**
+   * Open a non-terminal "tool" tab in the dock, or focus it if a tab with the same {@code toolId}
+   * is already open. The dock is made visible. The content is created lazily by {@code
+   * contentFactory} with the tab's container as its parent.
+   *
+   * @param toolId a stable identifier used to find/refocus the tab
+   * @param title the tab title
+   * @param image the tab image (may be null)
+   * @param closable whether the user can close the tab
+   * @param contentFactory builds the tab content given its container
+   * @return the content control of the (new or existing) tab
+   */
+  public Control focusOrOpenToolTab(
+      String toolId,
+      String title,
+      Image image,
+      boolean closable,
+      Function<Composite, Control> contentFactory) {
+    CTabItem existing = findToolTab(toolId);
+    if (existing != null) {
+      showDock();
+      terminalTabs.setSelection(existing);
+      return (Control) existing.getData(DATA_TOOL_CONTENT);
+    }
+    return createToolTab(toolId, title, image, closable, contentFactory);
+  }
+
+  /**
+   * Always open a <em>new</em> tool tab (for tools that allow several instances, e.g. multiple
+   * search-result tabs). The dock is made visible and the new tab selected.
+   *
+   * @param title the tab title
+   * @param image the tab image (may be null)
+   * @param closable whether the user can close the tab
+   * @param contentFactory builds the tab content given its container
+   * @return the content control of the new tab
+   */
+  public Control openToolTab(
+      String title, Image image, boolean closable, Function<Composite, Control> contentFactory) {
+    return createToolTab("tool-" + (toolTabCounter++), title, image, closable, contentFactory);
+  }
+
+  private Control createToolTab(
+      String toolId,
+      String title,
+      Image image,
+      boolean closable,
+      Function<Composite, Control> contentFactory) {
+    CTabItem item = new CTabItem(terminalTabs, closable ? SWT.CLOSE : SWT.NONE);
+    item.setText(title);
+    if (image != null) {
+      item.setImage(image);
+    }
+    item.setData(DATA_TOOL_ID, toolId);
+
+    Composite container = new Composite(terminalTabs, SWT.NONE);
+    container.setLayout(new FormLayout());
+    item.setControl(container);
+
+    Control content = contentFactory.apply(container);
+    FormData fdContent = new FormData();
+    fdContent.left = new FormAttachment(0, 0);
+    fdContent.top = new FormAttachment(0, 0);
+    fdContent.right = new FormAttachment(100, 0);
+    fdContent.bottom = new FormAttachment(100, 0);
+    content.setLayoutData(fdContent);
+    item.setData(DATA_TOOL_CONTENT, content);
+
+    // Attach shortcuts
+    hopGui.replaceKeyboardShortcutListeners(container, HopGuiKeyHandler.getInstance());
+
+    showDock();
+    terminalTabs.setSelection(item);
+    return content;
+  }
+
+  /** Find an open tool tab by its tool id, or {@code null} when not open. */
+  public CTabItem findToolTab(String toolId) {
+    if (toolId == null) {
+      return null;
+    }
+    for (CTabItem item : terminalTabs.getItems()) {
+      if (toolId.equals(item.getData(DATA_TOOL_ID))) {
+        return item;
+      }
+    }
+    return null;
+  }
+
   /** Toggle terminal panel visibility */
   @GuiMenuElement(
       root = HopGui.ID_MAIN_MENU,
@@ -391,7 +526,7 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
   @GuiKeyboardShortcut(control = true, key = 'j', global = true)
   @GuiOsxKeyboardShortcut(command = true, key = 'j', global = true)
   public void toggleTerminal() {
-    if (EnvironmentUtils.getInstance().isWeb()) {
+    if (EnvironmentUtils.getInstance().isWeb() || !terminalsEnabled) {
       return;
     }
     if (terminalVisible) {
@@ -410,7 +545,7 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
   @GuiKeyboardShortcut(control = true, shift = true, key = 'j', global = true)
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'j', global = true)
   public void newTerminal() {
-    if (EnvironmentUtils.getInstance().isWeb()) {
+    if (EnvironmentUtils.getInstance().isWeb() || !terminalsEnabled) {
       return;
     }
     createNewTerminal(null, null);
@@ -499,28 +634,33 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
       toolBar.setBackground(terminalTabs.getBackground());
     }
 
-    // Font size: increase
-    ToolItem increaseFontItem = new ToolItem(toolBar, SWT.PUSH);
-    increaseFontItem.setImage(GuiResource.getInstance().getImage("ui/images/zoom-in.svg", 16, 16));
-    increaseFontItem.setToolTipText(
-        BaseMessages.getString(PKG, "HopGuiTerminalPanel.Toolbar.IncreaseFont"));
-    increaseFontItem.addListener(SWT.Selection, e -> increaseTerminalFontSize());
-
-    // Font size: decrease
-    ToolItem decreaseFontItem = new ToolItem(toolBar, SWT.PUSH);
-    decreaseFontItem.setImage(GuiResource.getInstance().getImage("ui/images/zoom-out.svg", 16, 16));
-    decreaseFontItem.setToolTipText(
-        BaseMessages.getString(PKG, "HopGuiTerminalPanel.Toolbar.DecreaseFont"));
-    decreaseFontItem.addListener(SWT.Selection, e -> decreaseTerminalFontSize());
-
-    // Font size: reset to 100%
-    ToolItem resetFontItem = new ToolItem(toolBar, SWT.PUSH);
-    resetFontItem.setImage(GuiResource.getInstance().getImage("ui/images/zoom-100.svg", 16, 16));
-    resetFontItem.setToolTipText(
-        BaseMessages.getString(PKG, "HopGuiTerminalPanel.Toolbar.ResetFont"));
-    resetFontItem.addListener(SWT.Selection, e -> resetTerminalFontSize());
-
-    new ToolItem(toolBar, SWT.SEPARATOR);
+    // Font sizing only applies to terminal tabs, so the controls are terminal-gated.
+    if (terminalsEnabled) {
+      // Font size: increase
+      ToolItem increaseFontItem = new ToolItem(toolBar, SWT.PUSH);
+      increaseFontItem.setImage(
+          GuiResource.getInstance().getImage("ui/images/zoom-in.svg", 16, 16));
+      increaseFontItem.setToolTipText(
+          BaseMessages.getString(PKG, "HopGuiTerminalPanel.Toolbar.IncreaseFont"));
+      increaseFontItem.addListener(SWT.Selection, e -> increaseTerminalFontSize());
+
+      // Font size: decrease
+      ToolItem decreaseFontItem = new ToolItem(toolBar, SWT.PUSH);
+      decreaseFontItem.setImage(
+          GuiResource.getInstance().getImage("ui/images/zoom-out.svg", 16, 16));
+      decreaseFontItem.setToolTipText(
+          BaseMessages.getString(PKG, "HopGuiTerminalPanel.Toolbar.DecreaseFont"));
+      decreaseFontItem.addListener(SWT.Selection, e -> decreaseTerminalFontSize());
+
+      // Font size: reset to 100%
+      ToolItem resetFontItem = new ToolItem(toolBar, SWT.PUSH);
+      resetFontItem.setImage(GuiResource.getInstance().getImage("ui/images/zoom-100.svg", 16, 16));
+      resetFontItem.setToolTipText(
+          BaseMessages.getString(PKG, "HopGuiTerminalPanel.Toolbar.ResetFont"));
+      resetFontItem.addListener(SWT.Selection, e -> resetTerminalFontSize());
+
+      new ToolItem(toolBar, SWT.SEPARATOR);
+    }
 
     // Maximize/Minimize button
     final ToolItem maximizeItem = new ToolItem(toolBar, SWT.PUSH);
@@ -757,6 +897,9 @@ public class HopGuiTerminalPanel extends Composite implements TabClosable {
 
   /** Restore terminals from previous session; respects saved panel visibility (minimized state). */
   public void restoreTerminals() {
+    if (!terminalsEnabled) {
+      return;
+    }
     try {
       String namespace = HopNamespace.getNamespace();
 
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_de_DE.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_de_DE.properties
deleted file mode 100644
index c0be115b99..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_de_DE.properties
+++ /dev/null
@@ -1,36 +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.
-#
-#
-
-HopSearchPerspective.Description=Hop Suche
-HopSearchPerspective.GuiPlugin.Description=Hop Suche
-HopSearchPerspective.Header.Description.Text=Ort der Suche und Suchbegriffe eingeben, dann mit dem "Suche" Schalter best\u00E4tigen.
-HopSearchPerspective.Label.Location=Ort der Suche 
-HopSearchPerspective.Name=Suche
-HopSearchPerspective.Open.Button.Label=\u00D6ffnen  
-HopSearchPerspective.ResultsTable.Description.Field=Beschreibung
-HopSearchPerspective.ResultsTable.File.Field=Datei
-HopSearchPerspective.ResultsTable.Location.Field=Ort
-HopSearchPerspective.ResultsTable.MatchText.Field=\u00DCbereinstimmung
-HopSearchPerspective.ResultsTable.Name.Field=Name
-HopSearchPerspective.ResultsTable.Type.Field=Typ
-HopSearchPerspective.ResultsTable.Value.Field=Wert
-HopSearchPerspective.Search.Button.Label=Suchen  
-HopSearchPerspective.SearchStringOptions.Description=Suchbegriff      
-HopSearchPerspective.SearchStringOptions.Option1.Label=Gro\u00DF- und Kleinschreibung beachten? 
-HopSearchPerspective.SearchStringOptions.Option2.Label=Regul\u00E4rer Ausdruck ? 
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_en_US.properties
deleted file mode 100644
index c30b956a0f..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_en_US.properties
+++ /dev/null
@@ -1,35 +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.
-#
-
-HopSearchPerspective.Description=The Hop Search Perspective
-HopSearchPerspective.GuiPlugin.Description=Hop Search Perspective GUI
-HopSearchPerspective.Header.Description.Text=Select the location to search in and the search string. Then hit the ''Search'' button.
-HopSearchPerspective.Label.Location=Location: 
-HopSearchPerspective.Name=Search
-HopSearchPerspective.Open.Button.Label=Open  
-HopSearchPerspective.ResultsTable.Description.Field=Description
-HopSearchPerspective.ResultsTable.File.Field=File
-HopSearchPerspective.ResultsTable.Location.Field=Location
-HopSearchPerspective.ResultsTable.MatchText.Field=Matching Text
-HopSearchPerspective.ResultsTable.Name.Field=Name
-HopSearchPerspective.ResultsTable.Type.Field=Type
-HopSearchPerspective.ResultsTable.Value.Field=Value
-HopSearchPerspective.Search.Button.Label=Search  
-HopSearchPerspective.SearchStringOptions.Description=Search string:      
-HopSearchPerspective.SearchStringOptions.Option1.Label=Case sensitive? 
-HopSearchPerspective.SearchStringOptions.Option2.Label=Regular expression? 
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_es_AR.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_es_AR.properties
deleted file mode 100644
index 1692cdb472..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_es_AR.properties
+++ /dev/null
@@ -1,19 +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.
-#
-#
-
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_es_ES.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_es_ES.properties
deleted file mode 100644
index 46170efce3..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_es_ES.properties
+++ /dev/null
@@ -1,35 +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.
-#
-#
-
-HopSearchPerspective.Description=La perspectiva de la b\u00FAsqueda de hop
-HopSearchPerspective.GuiPlugin.Description=Perspectiva de b\u00FAsqueda de hop GUI
-HopSearchPerspective.Header.Description.Text=Seleccione la ubicaci\u00F3n en la que desea buscar y la cadena de b\u00FAsqueda. A continuaci\u00F3n, pulsa el bot\u00F3n ''Buscar''.
-HopSearchPerspective.Label.Location=Localizaci\u00F3n\:
-HopSearchPerspective.Name=Buscar
-HopSearchPerspective.Open.Button.Label=Abrir
-HopSearchPerspective.ResultsTable.Description.Field=Descripci\u00F3n
-HopSearchPerspective.ResultsTable.File.Field=Archivo
-HopSearchPerspective.ResultsTable.Location.Field=Localizaci\u00F3n
-HopSearchPerspective.ResultsTable.MatchText.Field=Texto coincidente
-HopSearchPerspective.ResultsTable.Name.Field=Nombre
-HopSearchPerspective.ResultsTable.Type.Field=Tipo
-HopSearchPerspective.ResultsTable.Value.Field=Valor
-HopSearchPerspective.Search.Button.Label=Buscar
-HopSearchPerspective.SearchStringOptions.Description=Buscar cadena\:
-HopSearchPerspective.SearchStringOptions.Option1.Label=\u00BFSensible a may\u00FAsculas?
-HopSearchPerspective.SearchStringOptions.Option2.Label=\u00BFExpresi\u00F3n regular? 
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_fr_FR.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_fr_FR.properties
deleted file mode 100644
index b3a476bd52..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_fr_FR.properties
+++ /dev/null
@@ -1,32 +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.
-#
-
-HopSearchPerspective.Description=Perspective de recherche
-HopSearchPerspective.Header.Description.Text=S\u00E9lectionnez la localisation o\u00F9 rechercher ainsi que la cha\u00EEne de recherche. Puis cliquez sur le bouton ''Rechercher''.
-HopSearchPerspective.Label.Location=Localisation: 
-HopSearchPerspective.Name=Rechercher
-HopSearchPerspective.Open.Button.Label=Ouvrir  
-HopSearchPerspective.ResultsTable.Description.Field=Description
-HopSearchPerspective.ResultsTable.File.Field=Fichier
-HopSearchPerspective.ResultsTable.Location.Field=Localisation
-HopSearchPerspective.ResultsTable.MatchText.Field=Texte de correspond
-HopSearchPerspective.ResultsTable.Name.Field=Nom
-HopSearchPerspective.ResultsTable.Value.Field=Valeur
-HopSearchPerspective.Search.Button.Label=Rechercher  
-HopSearchPerspective.SearchStringOptions.Description=Texte recherch\u00E9\u202F:      
-HopSearchPerspective.SearchStringOptions.Option1.Label=Sensible \u00E0 la case ? 
-HopSearchPerspective.SearchStringOptions.Option2.Label=Expression r\u00E9guli\u00E8re ? 
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_it_IT.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_it_IT.properties
deleted file mode 100644
index 6758ead696..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_it_IT.properties
+++ /dev/null
@@ -1,36 +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.
-#
-#
-#
-
-HopSearchPerspective.Description=Perspective di Ricerca
-HopSearchPerspective.GuiPlugin.Description=Hop Search Perspective GUI
-HopSearchPerspective.Header.Description.Text=Scegli la posizione in cui effettuare la ricerca e la stringa di ricerca. Poi premi ''Cerca'' per iniziare.
-HopSearchPerspective.Label.Location=Posizione: 
-HopSearchPerspective.Name=Ricerca
-HopSearchPerspective.Open.Button.Label=Apri  
-HopSearchPerspective.ResultsTable.Description.Field=Descrizione
-HopSearchPerspective.ResultsTable.File.Field=File
-HopSearchPerspective.ResultsTable.Location.Field=Posizione
-HopSearchPerspective.ResultsTable.MatchText.Field=Testo trovato
-HopSearchPerspective.ResultsTable.Name.Field=Nome
-HopSearchPerspective.ResultsTable.Type.Field=Tipo
-HopSearchPerspective.Search.Button.Label=Cerca  
-HopSearchPerspective.SearchStringOptions.Description=Stringa da cercare:      
-HopSearchPerspective.SearchStringOptions.Option1.Label=Case sensitive? 
-HopSearchPerspective.SearchStringOptions.Option2.Label=Regular expression? 
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_ja_JP.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_ja_JP.properties
deleted file mode 100644
index 1692cdb472..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_ja_JP.properties
+++ /dev/null
@@ -1,19 +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.
-#
-#
-
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_ko_KR.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_ko_KR.properties
deleted file mode 100644
index 1692cdb472..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_ko_KR.properties
+++ /dev/null
@@ -1,19 +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.
-#
-#
-
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_nl_NL.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_nl_NL.properties
deleted file mode 100644
index 1692cdb472..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_nl_NL.properties
+++ /dev/null
@@ -1,19 +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.
-#
-#
-
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_no_NO.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_no_NO.properties
deleted file mode 100644
index 1692cdb472..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_no_NO.properties
+++ /dev/null
@@ -1,19 +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.
-#
-#
-
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pl_PL.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pl_PL.properties
deleted file mode 100644
index 1692cdb472..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pl_PL.properties
+++ /dev/null
@@ -1,19 +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.
-#
-#
-
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pt_BR.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pt_BR.properties
deleted file mode 100644
index 32689d046f..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pt_BR.properties
+++ /dev/null
@@ -1,36 +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.
-#
-#
-
-HopSearchPerspective.Label.Location=local 
-HopSearchPerspective.Name=Busca
-HopSearchPerspective.Open.Button.Label=Abrir  
-HopSearchPerspective.ResultsTable.Description.Field=Descri\u00E7\u00E3o
-HopSearchPerspective.ResultsTable.File.Field=Arquivo
-HopSearchPerspective.ResultsTable.Location.Field=local
-HopSearchPerspective.ResultsTable.Name.Field=Nome
-HopSearchPerspective.ResultsTable.Type.Field=Tipo
-HopSearchPerspective.ResultsTable.Value.Field=Valor
-HopSearchPerspective.Search.Button.Label=Buscar  
-HopSearchPerspective.SearchStringOptions.Option1.Label=Diferenciar mai\u00FAsculas de min\u00FAsculas 
-HopSearchPerspective.SearchStringOptions.Option2.Label=Express\u00E3o regular ? 
-HopSearchPerspective.Description=A Perspectiva de Busca de Hop
-HopSearchPerspective.GuiPlugin.Description=Interface gráfica de Perspectiva de Busca de Hop
-HopSearchPerspective.Header.Description.Text=Selecione o local onde procurar e o texto de busca. Então aperte o botão ''Pesquisar''.
-HopSearchPerspective.ResultsTable.MatchText.Field=Texto correspondente
-HopSearchPerspective.SearchStringOptions.Description=Varredura de busca:
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pt_PT.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pt_PT.properties
deleted file mode 100644
index fbbd3ae664..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_pt_PT.properties
+++ /dev/null
@@ -1,19 +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.
-#
-
-HopSearchPerspective.GuiPlugin.Description=Janela de busca do Hop
-HopSearchPerspective.Name=Buscar
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_zh_CN.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_zh_CN.properties
deleted file mode 100644
index b545219eb3..0000000000
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/search/messages/messages_zh_CN.properties
+++ /dev/null
@@ -1,36 +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.
-#
-#
-
-HopSearchPerspective.Description=Hop \u641C\u7D22\u89C6\u56FE
-HopSearchPerspective.GuiPlugin.Description=Hop \u641C\u7D22\u900F\u89C6\u56FE GUI
-HopSearchPerspective.Header.Description.Text=\u9009\u62E9\u8981\u641C\u7D22\u7684\u8303\u56F4\u548C\u8FC7\u6EE4\u6761\u4EF6\u540E, \u70B9\u51FB\u201C\u641C\u7D22\u201D\u6309\u94AE
-HopSearchPerspective.Label.Location=\u641C\u7D22\u8303\u56F4\:
-HopSearchPerspective.Name=\u641C\u7D22
-HopSearchPerspective.Open.Button.Label=\u6253\u5F00
-HopSearchPerspective.ResultsTable.Description.Field=\u63CF\u8FF0
-HopSearchPerspective.ResultsTable.File.Field=\u6587\u4EF6
-HopSearchPerspective.ResultsTable.Location.Field=\u6240\u5728\u4F4D\u7F6E
-HopSearchPerspective.ResultsTable.MatchText.Field=\u5339\u914D\u6587\u672C
-HopSearchPerspective.ResultsTable.Name.Field=\u540D\u79F0
-HopSearchPerspective.ResultsTable.Type.Field=\u7C7B\u578B
-HopSearchPerspective.ResultsTable.Value.Field=\u503C
-HopSearchPerspective.Search.Button.Label=\u641C\u7D22
-HopSearchPerspective.SearchStringOptions.Description=\u8FC7\u6EE4\u5668\:
-HopSearchPerspective.SearchStringOptions.Option1.Label=\u5927\u5C0F\u5199\u654F\u611F
-HopSearchPerspective.SearchStringOptions.Option2.Label=\u6B63\u5219\u8868\u8FBE\u5F0F
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
new file mode 100644
index 0000000000..170a653cd1
--- /dev/null
+++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
@@ -0,0 +1,54 @@
+#
+#
+# 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.
+#
+
+SearchEverywhereDialog.Shell.Title=Search
+SearchEverywhereDialog.Search.Placeholder=Search pipelines, workflows, metadata, variables and actions...
+SearchEverywhereDialog.CaseSensitive.Label=Aa
+SearchEverywhereDialog.CaseSensitive.Tooltip=Case sensitive
+SearchEverywhereDialog.RegEx.Label=.*
+SearchEverywhereDialog.RegEx.Tooltip=Regular expression
+SearchEverywhereDialog.Column.Name=Name
+SearchEverywhereDialog.Column.Location=Location
+SearchEverywhereDialog.Group.Open=Open files
+SearchEverywhereDialog.Group.Project=Project files
+SearchEverywhereDialog.Hint.Label=Type to search across all loaded locations
+SearchEverywhereDialog.ShowAll.Label=<a>Show all {0} results</a>
+SearchEverywhereDialog.ShowAll.TabTitle=Search results
+SearchEverywhereDialog.Error.Title=Error
+SearchEverywhereDialog.Error.Message=Error while searching
+SearchEverywhereDialog.Error.Open.Message=Error opening {0}
+
+HopGuiSearchResultsPanel.Label.Search=Search:
+HopGuiSearchResultsPanel.Option.CaseSensitive=Case sensitive?
+HopGuiSearchResultsPanel.Option.RegEx=Regular expression?
+HopGuiSearchResultsPanel.Refresh.Button.Label=Refresh
+HopGuiSearchResultsPanel.Refresh.Button.Tooltip=Reload the searchable objects (open files, project files, metadata and variables)
+HopGuiSearchResultsPanel.Open.Button.Label=Open
+HopGuiSearchResultsPanel.Column.Name=Name
+HopGuiSearchResultsPanel.Column.Match=Matched text
+HopGuiSearchResultsPanel.Section.Open=Open files
+HopGuiSearchResultsPanel.Section.Project=Project files
+HopGuiSearchResultsPanel.Detail.Type=Type
+HopGuiSearchResultsPanel.Detail.Name=Name
+HopGuiSearchResultsPanel.Detail.File=File
+HopGuiSearchResultsPanel.Detail.Location=Location
+HopGuiSearchResultsPanel.Detail.Match=Matching Text
+HopGuiSearchResultsPanel.Detail.Value=Value
+HopGuiSearchResultsPanel.Detail.Description=Description
+HopGuiSearchResultsPanel.Error.Open.Title=Error
+HopGuiSearchResultsPanel.Error.Open.Message=Error opening {0}
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
new file mode 100644
index 0000000000..0e8bab1136
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.search;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+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.SearchResult;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
+import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper.SearchObjectGroup;
+import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper.SearchSection;
+import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper.SearchTypeGroup;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for the two-tier grouping that both search windows render. */
+class HopGuiSearchHelperTest {
+
+  /** A pipeline searchable (its searchable object is a PipelineMeta so it can be "open"). */
+  private static ISearchable pipeline(String name, String filename) {
+    ISearchable searchable = mock(ISearchable.class);
+    when(searchable.getName()).thenReturn(name);
+    when(searchable.getType()).thenReturn(HopPipelineFileType.PIPELINE_FILE_TYPE_DESCRIPTION);
+    when(searchable.getFilename()).thenReturn(filename);
+    when(searchable.getSearchableObject()).thenReturn(mock(PipelineMeta.class));
+    return searchable;
+  }
+
+  /** A non-file searchable (metadata / variable) - never "open", grouped by its type. */
+  private static ISearchable other(String name, String type) {
+    ISearchable searchable = mock(ISearchable.class);
+    when(searchable.getName()).thenReturn(name);
+    when(searchable.getType()).thenReturn(type);
+    when(searchable.getFilename()).thenReturn(null);
+    when(searchable.getSearchableObject()).thenReturn(new Object());
+    return searchable;
+  }
+
+  private static ISearchResult nameMatch(ISearchable searchable) {
+    return new SearchResult(searchable, searchable.getName(), "name", null, searchable.getName());
+  }
+
+  private static ISearchResult fieldMatch(ISearchable searchable, String component, String value) {
+    return new SearchResult(searchable, value, component + " : field", component, value);
+  }
+
+  private static Map<String, Integer> source(ISearchable searchable, int index) {
+    Map<String, Integer> map = new HashMap<>();
+    map.put(HopGuiSearchHelper.searchableKey(searchable), index);
+    return map;
+  }
+
+  @Test
+  void openPipelineGoesToOpenSection() {
+    ISearchable open = pipeline("loader", "/p/loader.hpl");
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(nameMatch(open)), source(open, 0));
+
+    assertEquals(1, sections.size());
+    assertEquals(HopGuiSearchHelper.SECTION_OPEN, sections.get(0).getKey());
+    assertEquals(1, sections.get(0).getObjectCount());
+  }
+
+  @Test
+  void projectPipelineGoesToProjectSection() {
+    ISearchable onDisk = pipeline("loader", "/p/loader.hpl");
+    // Found in the project location (index 1), not the GUI/open location (index 0).
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(nameMatch(onDisk)), source(onDisk, 1));
+
+    assertEquals(1, sections.size());
+    assertEquals(HopGuiSearchHelper.SECTION_PROJECT, sections.get(0).getKey());
+  }
+
+  @Test
+  void metadataIsNeverOpenEvenFromGuiLocation() {
+    ISearchable meta = other("mysql-prod", "Relational Database Connection");
+    // Even though it is reported by the GUI location (index 0), it is not a file => Project.
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(nameMatch(meta)), source(meta, 0));
+
+    assertEquals(HopGuiSearchHelper.SECTION_PROJECT, sections.get(0).getKey());
+    assertFalse(HopGuiSearchHelper.isOpenObject(meta, source(meta, 0)));
+  }
+
+  @Test
+  void nameMatchMarksObjectButAddsNoChildRow() {
+    ISearchable open = pipeline("loader", "/p/loader.hpl");
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(nameMatch(open)), source(open, 0));
+
+    SearchObjectGroup group = sections.get(0).getTypeGroups().get(0).getObjects().get(0);
+    assertTrue(group.getMatches().isEmpty(), "a name match should not become a child row");
+  }
+
+  @Test
+  void fieldMatchesBecomeChildRowsUnderTheirObject() {
+    ISearchable open = pipeline("loader", "/p/loader.hpl");
+    ISearchResult name = nameMatch(open);
+    ISearchResult t1 = fieldMatch(open, "Table input", "select 1");
+    ISearchResult t2 = fieldMatch(open, "Output", "customers");
+
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(name, t1, t2), source(open, 0));
+
+    // One object node with two inner matches.
+    assertEquals(1, sections.get(0).getObjectCount());
+    SearchObjectGroup group = sections.get(0).getTypeGroups().get(0).getObjects().get(0);
+    assertEquals(2, group.getMatches().size());
+    // The name match represents the object node itself.
+    assertSame(name, group.getPrimary());
+  }
+
+  @Test
+  void identicalMatchesCollapseButDistinctOnesAreKept() {
+    ISearchable pipe = pipeline("loader", "/p/loader.hpl");
+    // The same transform matched as both its "name" and its "plugin name" (same component + value).
+    ISearchResult byName =
+        new SearchResult(
+            pipe, "PG Bulk Loader", "transform name", "PG Bulk Loader", "PG Bulk Loader");
+    ISearchResult byPlugin =
+        new SearchResult(
+            pipe, "PG Bulk Loader", "transform plugin name", "PG Bulk Loader", "PG Bulk Loader");
+    // A genuinely different match inside the same transform (different value) must be kept.
+    ISearchResult byField =
+        new SearchResult(
+            pipe,
+            "public.orders",
+            "transform property : schema",
+            "PG Bulk Loader",
+            "public.orders");
+
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(List.of(byName, byPlugin, byField), source(pipe, 0));
+
+    SearchObjectGroup group = sections.get(0).getTypeGroups().get(0).getObjects().get(0);
+    assertEquals(2, group.getMatches().size(), "duplicate (component, value) rows collapse to one");
+  }
+
+  @Test
+  void typeGroupsAreOrderedPipelinesThenOthersThenVariables() {
+    ISearchable pipe = pipeline("loader", "/p/loader.hpl");
+    ISearchable conn = other("mysql-prod", "Relational Database Connection");
+    ISearchable variable = other("HTTP_PROXY", "Variable");
+
+    Map<String, Integer> sourceByKey = new HashMap<>();
+    sourceByKey.put(HopGuiSearchHelper.searchableKey(pipe), 1);
+    sourceByKey.put(HopGuiSearchHelper.searchableKey(conn), 0);
+    sourceByKey.put(HopGuiSearchHelper.searchableKey(variable), 0);
+
+    // Feed them in a deliberately "wrong" order to prove the helper sorts the type groups.
+    List<SearchSection> sections =
+        HopGuiSearchHelper.groupResults(
+            List.of(nameMatch(variable), nameMatch(conn), nameMatch(pipe)), sourceByKey);
+
+    // All three land in the Project section (pipe is on disk, the others are non-files).
+    assertEquals(1, sections.size());
+    List<SearchTypeGroup> typeGroups = sections.get(0).getTypeGroups();
+    assertEquals(HopPipelineFileType.PIPELINE_FILE_TYPE_DESCRIPTION, typeGroups.get(0).getType());
+    assertEquals("Relational Database Connection", typeGroups.get(1).getType());
+    assertEquals("Variable", typeGroups.get(2).getType());
+  }
+
+  @Test
+  void isNameMatchDistinguishesNameFromFieldMatches() {
+    ISearchable open = pipeline("loader", "/p/loader.hpl");
+    assertTrue(HopGuiSearchHelper.isNameMatch(nameMatch(open)));
+    assertFalse(HopGuiSearchHelper.isNameMatch(fieldMatch(open, "Table input", "select 1")));
+  }
+}
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDockTest.java
similarity index 98%
rename from ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
rename to ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDockTest.java
index e14dac39ff..3b86bb96d0 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiBottomDockTest.java
@@ -25,13 +25,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import org.junit.jupiter.api.Test;
 
 /**
- * Unit tests for HopGuiTerminalPanel business logic.
+ * Unit tests for HopGuiBottomDock business logic.
  *
  * <p>Note: This class tests non-UI logic only. Full integration tests with SWT UI components
  * require a display environment and are better suited for integration tests.
  */
 @SuppressWarnings("all")
-class HopGuiTerminalPanelTest {
+class HopGuiBottomDockTest {
 
   @Test
   void testExtractShellNameLogic() {