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 3a9a29ff1e issue #7756: harden Hop GUI search against large result
floods (#7757)
3a9a29ff1e is described below
commit 3a9a29ff1eb9ff1c2679a40d499e1cf2b29bba25
Author: Matt Casters <[email protected]>
AuthorDate: Mon Aug 3 08:58:33 2026 +0200
issue #7756: harden Hop GUI search against large result floods (#7757)
Keep Search Everywhere responsive when projects contain large CSV/JSON
and other text files: skip oversized files, cap global and per-file hits,
delay content search until a minimum query length, and run analysis off
the UI thread. Expose all limits in Search settings and Configuration,
and update the user manual for the current Search UX.
---
.../images/hop-gui/search-options-dialog.png | Bin 0 -> 136733 bytes
docs/hop-user-manual/modules/ROOT/nav.adoc | 1 +
.../ROOT/pages/getting-started/hop-gui.adoc | 2 -
.../modules/ROOT/pages/hop-gui/index.adoc | 3 +-
.../pages/hop-gui/perspective-configuration.adoc | 2 +-
.../ROOT/pages/hop-gui/perspective-search.adoc | 100 +++++++-
.../modules/ROOT/pages/hop-gui/perspectives.adoc | 8 +-
.../file/types/text/BaseTextExplorerFileType.java | 8 +
.../hop/ui/hopgui/search/HopGuiSearchHelper.java | 89 ++++++-
.../ui/hopgui/search/HopGuiSearchResultsPanel.java | 155 +++++++++---
.../ui/hopgui/search/SearchEverywhereDialog.java | 180 +++++++++++---
.../hop/ui/hopgui/search/SearchSettingsDialog.java | 237 ++++++++++++++++++
.../search/TextFileContentSearchableAnalyser.java | 31 ++-
.../hopgui/search/config/SearchAnalysisResult.java | 48 ++++
.../hop/ui/hopgui/search/config/SearchConfig.java | 90 +++++++
.../hopgui/search/config/SearchConfigPlugin.java | 270 +++++++++++++++++++++
.../search/config/SearchConfigSingleton.java | 86 +++++++
.../hop/ui/hopgui/search/config/SearchLimits.java | 95 ++++++++
.../config/messages/messages_en_US.properties | 32 +++
.../search/messages/messages_en_US.properties | 28 +++
.../ui/hopgui/search/HopGuiSearchHelperTest.java | 92 +++++++
.../TextFileContentSearchableAnalyserTest.java | 18 ++
.../ui/hopgui/search/config/SearchLimitsTest.java | 71 ++++++
23 files changed, 1561 insertions(+), 85 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/search-options-dialog.png
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/search-options-dialog.png
new file mode 100644
index 0000000000..8c2dde001f
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/search-options-dialog.png
differ
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 2b5f548131..487d4b5ae7 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -31,6 +31,7 @@ under the License.
** xref:hop-gui/hop-gui-popup-dialog.adoc[Popup Dialog]
** xref:hop-gui/notes.adoc[Notes on pipelines and workflows]
** xref:hop-gui/perspectives.adoc[Perspectives]
+** xref:hop-gui/perspective-search.adoc[Search]
** xref:hop-gui/hop-gui-git.adoc[Working with git]
** xref:hop-gui/shortcuts.adoc[Keyboard Shortcuts]
** xref:hop-gui/hop-web.adoc[Hop Web]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
b/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
index ff04dc77cf..19b882db70 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
@@ -71,8 +71,6 @@ image:getting-started/gs-hop-gui-overview-2.png[Hop Gui
Overview]
** image:icons/folder.svg[width="24px"]
xref:hop-gui/perspective-file-explorer.adoc[File explorer]
-** image:icons/search.svg[width="24px"]
xref:hop-gui/perspective-search.adoc[Search]
-
** image:icons/plugin.svg[width="24px"]
xref:hop-gui/perspective-plugin.adoc[Plugins explorer]
** image:icons/neo4j_logo.svg[width="24px"]
xref:hop-gui/perspective-neo4j.adoc[Neo4j]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
index 14e444b002..bd93500113 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
@@ -23,7 +23,7 @@ under the License.
Hop Gui is Hop's visual development environment. You'll spend a lot of time
here in the various perspectives of Hop GUI.
-You'll spend most of your time building, previewing running and debugging
workflows and pipelines, but there's also a lot of configuration that can be
done from Hop Gui's metadata perspective, you can search for any metadata with
the search perspective, manage a variety of files and even version control them
from the file explorer perspective and much more.
+You'll spend most of your time building, previewing running and debugging
workflows and pipelines, but there's also a lot of configuration that can be
done from Hop Gui's metadata perspective, you can search across projects with
xref:hop-gui/perspective-search.adoc[Search] (`CTRL-F`), manage a variety of
files and even version control them from the file explorer perspective and much
more.
This page covers a number of topics that are very specific to working with Hop
Gui.
Check the chapters on xref:/pipeline/pipelines.adoc[pipelines] and
xref:/workflow/workflows.adoc[workflows] to learn more about creating and
running pipelines and workflows in Hop Gui.
@@ -34,6 +34,7 @@ Covered here are:
* xref:hop-gui/hop-gui-popup-dialog.adoc[Popup Dialog]: the versatile popup
dialog to work with metadata, workflows, pipelines and hops
* xref:hop-gui/notes.adoc[Notes on pipelines and workflows]: canvas notes,
including Markdown formatting, types, links, and images
* xref:hop-gui/perspectives.adoc[Perspectives]: the various perspectives in
the Hop Gui
+* xref:hop-gui/perspective-search.adoc[Search]: Search Everywhere (`CTRL-F`),
limits, and results panel
* xref:hop-gui/hop-gui-git.adoc[Working with git]: Manage files in the git
version control system
* xref:hop-gui/shortcuts.adoc[Keyboard Shortcuts]: a list of the keyboard
shortcuts that are available in Hop Gui.
* xref:hop-gui/hop-web.adoc[Hop Web]: a web version of Hop Gui that provides
the exact same functionality and user experience as the desktop version
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
index eac252128e..263d5a1e4b 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
@@ -32,7 +32,7 @@ The configuration perspective groups configuration options in
the following tabs
* **General** options control the behavior of Apache Hop and Hop Gui.
* **Look & Feel** control how Hop Gui looks like on your desktop or in
your browser.
-* **Plugin** options provide configuration options to control the behavior of
your available Apache Hop plugins.
+* **Plugin** options provide configuration options to control the behavior of
your available Apache Hop plugins (including Explorer and
xref:hop-gui/perspective-search.adoc[Search] limits).
* **System variables** allows you to set and configure Apache Hop global
variables.
== Detailed configuration tabs overview
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
index a62068a663..9f04ed8fc5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-search.adoc
@@ -15,18 +15,104 @@ specific language governing permissions and limitations
under the License.
////
:imagesdir: ../assets/images
+:description: Search pipelines, workflows, metadata, variables, text files and
menu actions from Hop Gui with Search Everywhere, optional safety limits, and a
full results panel.
-= Search Perspective
+= Search
Icon: image:icons/search.svg[width="24px"]
+Keyboard shortcut: `CTRL-F` (macOS: `Cmd-F`)
+
+Menu: *Edit → Find*
+
== Description
-The Search perspective allows you to search through all metadata in your Hop
projects.
-This not only includes workflows and actions, pipelines and transforms, but
any other metadata type known to Hop.
-The search options are the location (all objects in Hop Gui or only the
current project), and whether the search string is case sensitive (default:
case insensitive) or the search string is a regular expression (default: false).
+Hop Gui search finds matches across your open files, project files (pipelines,
workflows, and other searchable types), metadata, variables, and global menu
actions.
+
+Search is available as a quick *Search Everywhere* popup and as a fuller
results view in the bottom dock (*Show all results*).
+
+TIP: For large or batch searches from a terminal (including remote SSH
sessions), use the xref:hop-tools/hop-search.adoc[hop-search] command-line
tool. It is better suited to huge hit lists than the GUI.
+
+== Search Everywhere popup
+
+Open search with `CTRL-F` or *Edit → Find*. Type in the search field to filter
results (search-as-you-type can be turned off in settings).
+
+* *Aa* — case-sensitive matching
+* `.*` — treat the query as a regular expression
+* image:icons/gear.svg[width="16px"] — open *Search settings* (limits and
options; see below)
+
+Results are grouped by *Open files* and *Project files*, then by type
(Pipeline, Workflow, metadata type, and so on). Use the arrow keys to move,
*Enter* to open the selection, and *Escape* to close the popup.
+
+*Show all results* hands the same query to a search tab in the bottom dock for
deeper browsing (details panel, open selected item).
+
+=== What is searched
+
+|===
+|Scope |Notes
+
+|Open files
+|Pipelines, workflows and text files open in Hop Gui tabs
+
+|Project files
+|Registered searchable file types under the project home (pipelines,
workflows, SQL, XML, YAML, CSV, JSON, and similar), subject to size and options
below
+
+|Metadata and variables
+|Connections, run configurations, variables, and other metadata known to Hop
+
+|Actions
+|Enabled main-menu commands whose labels match the query
+|===
+
+Object *names* are always considered. *Content* matching (including line-level
hits in text files) only runs when the query is long enough (default: 3
characters), so short prefixes while typing do not flood the UI with data-file
matches.
+
+== Search settings
+
+Open the gear on the Search Everywhere bar (or on the results panel toolbar),
or configure the same options under the
xref:hop-gui/perspective-configuration.adoc[Configuration] perspective →
*Plugin* options → *Search*.
+
+image::hop-gui/search-options-dialog.png[Search settings dialog,width="80%"]
+
+[%header, width=90%, cols="2,5,1"]
+|===
+|Option |Description |Default
+
+|Min. characters for content
+|Minimum query length before field and text-file *content* is searched. Names
still match for shorter queries.
+|`3`
+
+|Maximum results
+|Hard cap on how many results Hop Gui builds for one search. Further matches
are not collected so the UI stays responsive.
+|`500`
+
+|Max. matches per text file
+|Stop scanning a single text file after this many matching lines.
+|`20`
+
+|Max. text file size (MB)
+|Skip text files larger than this size for content search. Million-row
CSV/JSON files should be searched with dedicated tools or
xref:hop-tools/hop-search.adoc[hop-search], not loaded into Hop Gui.
+|`1`
+
+|Search-as-you-type delay (ms)
+|How long to wait after the last keystroke before running a live search.
+|`300`
+
+|Include project text files (CSV, JSON, …)
+|When enabled, project text files are content-searched (within size and match
limits). Open text files remain eligible either way. Disable to focus on
pipelines, workflows and metadata.
+|yes
+
+|Search as you type
+|When enabled, search runs automatically after the delay above. *Enter* always
runs a search immediately.
+|yes
+|===
+
+Settings are stored in the Hop configuration (`hop-config.json`) under the
`search` key and apply to both the popup and the results panel.
+
+== Full results panel
+
+From *Show all results*, or when Hop Gui opens a search tab for references,
the bottom dock shows a toolbar (query, case/regex, refresh, settings), a
grouped results tree, and a details side panel. Select a hit and use *Open* to
jump to the matching object.
+
+Refresh reloads searchable objects from disk and open tabs after you change
project files outside Hop Gui.
-The result table shows the type, name, filename, location, matching text and
description.
-Use the 'Open' button at the bottom of the perspective to jump directly to the
selected metadata object (e.g. open a transform in a pipeline).
+== Related
-TIP: The xref:hop-tools/hop-search.adoc[hop-search] tool provides the same
functionality as the Search Perspective through the command line instead of in
a GUI.
+* xref:hop-tools/hop-search.adoc[hop-search] — CLI search for projects and
environments
+* xref:hop-gui/perspective-configuration.adoc[Configuration perspective] —
other Hop Gui options
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
index 6a05fb7e61..8669243dd9 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
@@ -17,7 +17,7 @@ under the License.
= Hop Gui Perspectives
:imagesdir: ../assets/images
-:description: Hop Gui provides a number of perspectives for a variety of
tasks. You'll spend most of your time in the data orchestration perspective,
building workflows and pipelines, but other perspectives for metadata
management, file management, search etc offer tons of functionality.
+:description: Hop Gui provides a number of perspectives for a variety of
tasks. You'll spend most of your time in the data orchestration perspective,
building workflows and pipelines, but other perspectives for metadata
management, file management and more offer tons of functionality.
Hop comes with a number of perspectives built in.
@@ -36,8 +36,8 @@ Here are the available perspectives:
* image:icons/folder.svg[width="24px"]
xref:hop-gui/perspective-file-explorer.adoc[File explorer]
-* image:icons/search.svg[width="24px"]
xref:hop-gui/perspective-search.adoc[Search]
-
* image:icons/plugin.svg[width="24px"]
xref:hop-gui/perspective-plugin.adoc[Plugins explorer]
-* image:icons/neo4j_logo.svg[width="24px"]
xref:hop-gui/perspective-neo4j.adoc[Neo4j]
\ No newline at end of file
+* image:icons/neo4j_logo.svg[width="24px"]
xref:hop-gui/perspective-neo4j.adoc[Neo4j]
+
+TIP: Project-wide search is not a left-toolbar perspective. Open it with
`CTRL-F` (*Edit → Find*). See xref:hop-gui/perspective-search.adoc[Search].
\ No newline at end of file
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
index 02faf65e5a..04de85b905 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/text/BaseTextExplorerFileType.java
@@ -36,6 +36,7 @@ import
org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import
org.apache.hop.ui.hopgui.perspective.explorer.file.types.base.BaseExplorerFileType;
import org.apache.hop.ui.hopgui.search.HopGuiTextFileSearchable;
import org.apache.hop.ui.hopgui.search.TextFileContent;
+import org.apache.hop.ui.hopgui.search.config.SearchLimits;
public abstract class BaseTextExplorerFileType<T extends
BaseTextExplorerFileTypeHandler>
extends BaseExplorerFileType<T> {
@@ -73,6 +74,13 @@ public abstract class BaseTextExplorerFileType<T extends
BaseTextExplorerFileTyp
if (!fileObject.exists()) {
throw new HopException("File '" + filename + "' doesn't exist");
}
+ // Never load very large data files into GUI search (million-row
CSV/JSON, …).
+ // Users should use dedicated tools for those. Limit is configurable
under Search options.
+ long fileSize = fileObject.getContent().getSize();
+ long maxBytes = SearchLimits.fromConfig().getMaxTextFileSizeBytes();
+ if (maxBytes > 0 && fileSize > maxBytes) {
+ return null;
+ }
String text;
try (InputStream inputStream = HopVfs.getInputStream(fileObject)) {
StringWriter writer = new StringWriter();
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
index d2d46baf52..b553a57d8f 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelper.java
@@ -47,6 +47,8 @@ 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.ui.hopgui.search.config.SearchAnalysisResult;
+import org.apache.hop.ui.hopgui.search.config.SearchLimits;
import org.apache.hop.workflow.WorkflowMeta;
/**
@@ -134,17 +136,60 @@ public final class HopGuiSearchHelper {
Collection<ISearchable> searchables,
ISearchQuery query,
Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap) {
+ return analyseLimited(searchables, query, analyserMap, null,
null).getResults();
+ }
+
+ /**
+ * Like {@link #analyse} with GUI safety limits: global result cap, optional
skip of project text
+ * files, and skip of text-file content when the query is shorter than the
configured minimum.
+ *
+ * @param sourceByKey location indexes from {@link #enumerateAll}; used to
keep open text files
+ * when project text files are excluded; may be null
+ */
+ public static SearchAnalysisResult analyseLimited(
+ Collection<ISearchable> searchables,
+ ISearchQuery query,
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap,
+ SearchLimits limits,
+ Map<String, Integer> sourceByKey) {
+ SearchLimits effective = limits == null ? SearchLimits.defaults() : limits;
+ boolean contentAllowed =
effective.allowsContentSearch(query.getSearchString());
+ boolean projectTextExcluded = !effective.isIncludeProjectTextFiles();
+ boolean truncated = false;
+
List<ISearchResult> results = new ArrayList<>();
for (ISearchable searchable : searchables) {
+ if (results.size() >= effective.getMaxResults()) {
+ truncated = true;
+ break;
+ }
Object object = searchable.getSearchableObject();
- if (object != null) {
- ISearchableAnalyser searchableAnalyser =
analyserMap.get(object.getClass());
- if (searchableAnalyser != null) {
- results.addAll(searchableAnalyser.search(searchable, query));
+ if (object == null) {
+ continue;
+ }
+ if (object instanceof TextFileContent) {
+ if (!contentAllowed) {
+ continue;
+ }
+ if (projectTextExcluded && !isOpenObject(searchable, sourceByKey)) {
+ continue;
}
}
+ ISearchableAnalyser searchableAnalyser =
analyserMap.get(object.getClass());
+ if (searchableAnalyser == null) {
+ continue;
+ }
+ List<ISearchResult> found = searchableAnalyser.search(searchable, query);
+ for (ISearchResult result : found) {
+ if (results.size() >= effective.getMaxResults()) {
+ truncated = true;
+ break;
+ }
+ results.add(result);
+ }
}
- return results;
+ return new SearchAnalysisResult(
+ results, truncated, !contentAllowed, projectTextExcluded &&
contentAllowed);
}
/**
@@ -164,7 +209,26 @@ public final class HopGuiSearchHelper {
ISearchQuery query,
Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap,
boolean fuzzyNames) {
- List<ISearchResult> results = new ArrayList<>(analyse(searchables, query,
analyserMap));
+ return analyseRankedLimited(searchables, query, analyserMap, fuzzyNames,
null, null)
+ .getResults();
+ }
+
+ /**
+ * Ranked analysis with GUI safety limits. Name matching always runs;
text-file content is gated
+ * by {@link SearchLimits}.
+ */
+ public static SearchAnalysisResult analyseRankedLimited(
+ Collection<ISearchable> searchables,
+ ISearchQuery query,
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analyserMap,
+ boolean fuzzyNames,
+ SearchLimits limits,
+ Map<String, Integer> sourceByKey) {
+ SearchLimits effective = limits == null ? SearchLimits.defaults() : limits;
+ SearchAnalysisResult analysed =
+ analyseLimited(searchables, query, analyserMap, effective,
sourceByKey);
+ List<ISearchResult> results = new ArrayList<>(analysed.getResults());
+ boolean truncated = analysed.isTruncated();
SearchMatcher nameMatcher =
new SearchMatcher(query.getSearchString(), query.isCaseSensitive(),
query.isRegEx(), true);
@@ -175,6 +239,10 @@ public final class HopGuiSearchHelper {
covered.add(searchableKey(result.getMatchingSearchable()));
}
for (ISearchable searchable : searchables) {
+ if (results.size() >= effective.getMaxResults()) {
+ truncated = true;
+ break;
+ }
String name = searchable.getName();
if (name != null
&& covered.add(searchableKey(searchable))
@@ -190,7 +258,11 @@ public final class HopGuiSearchHelper {
relevance.put(result, relevance(result, nameMatcher));
}
results.sort((a, b) -> Double.compare(relevance.get(b), relevance.get(a)));
- return results;
+ return new SearchAnalysisResult(
+ results,
+ truncated,
+ analysed.isContentSearchSkipped(),
+ analysed.isProjectTextFilesExcluded());
}
/**
@@ -281,6 +353,9 @@ public final class HopGuiSearchHelper {
if (object instanceof PipelineMeta
|| object instanceof WorkflowMeta
|| object instanceof TextFileContent) {
+ if (sourceByKey == null) {
+ return false;
+ }
Integer source = sourceByKey.get(searchableKey(searchable));
return source != null && source == 0;
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
index eb2bf4ddb4..f183081e3a 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiSearchResultsPanel.java
@@ -17,10 +17,12 @@
package org.apache.hop.ui.hopgui.search;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.hop.core.Const;
import org.apache.hop.core.search.ISearchResult;
import org.apache.hop.core.search.ISearchable;
@@ -38,9 +40,12 @@ 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.search.config.SearchAnalysisResult;
+import org.apache.hop.ui.hopgui.search.config.SearchLimits;
import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.layout.FormAttachment;
import org.eclipse.swt.layout.FormData;
@@ -50,6 +55,7 @@ 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.Display;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Tree;
@@ -77,9 +83,6 @@ public class HopGuiSearchResultsPanel extends Composite {
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;
@@ -87,6 +90,8 @@ public class HopGuiSearchResultsPanel extends Composite {
private Combo wSearchString;
private Button wCaseSensitive;
private Button wRegEx;
+ private Button wSettings;
+ private Label wlStatus;
private SashForm sash;
private Tree wTree;
private Button wbOpen;
@@ -107,6 +112,14 @@ public class HopGuiSearchResultsPanel extends Composite {
private Map<Class<ISearchableAnalyser>, ISearchableAnalyser> cachedAnalysers;
private final Runnable searchRunnable = () -> search(new Event());
+ private final AtomicInteger searchGeneration = new AtomicInteger();
+ private final ExecutorService searchExecutor =
+ Executors.newSingleThreadExecutor(
+ r -> {
+ Thread t = new Thread(r, "hop-gui-search-panel");
+ t.setDaemon(true);
+ return t;
+ });
/**
* When true, programmatic changes to the search field (e.g. showing a
reference label) must not
@@ -133,6 +146,8 @@ public class HopGuiSearchResultsPanel extends Composite {
addDisposeListener(
e -> {
// The Dispose event fires before the child sash is released, so
weights are still valid.
+ searchGeneration.incrementAndGet();
+ searchExecutor.shutdownNow();
saveState();
hopGui.getEventsHandler().removeEventListeners(eventListenerId);
});
@@ -150,11 +165,11 @@ public class HopGuiSearchResultsPanel extends Composite {
layout.marginBottom = margin;
setLayout(layout);
- // --- Top toolbar: search field, case/regex toggles, refresh ---
+ // --- Top toolbar: search field, case/regex toggles, refresh, settings ---
//
Composite toolbar = new Composite(this, SWT.NONE);
PropsUi.setLook(toolbar);
- toolbar.setLayout(new GridLayout(5, false));
+ toolbar.setLayout(new GridLayout(7, false));
FormData fdToolbar = new FormData();
fdToolbar.left = new FormAttachment(0, 0);
fdToolbar.right = new FormAttachment(100, 0);
@@ -169,7 +184,7 @@ public class HopGuiSearchResultsPanel extends Composite {
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.Modify, e -> onSearchModified());
wSearchString.addListener(
SWT.DefaultSelection,
e -> {
@@ -200,6 +215,29 @@ public class HopGuiSearchResultsPanel extends Composite {
search(e);
});
+ wSettings = new Button(toolbar, SWT.PUSH);
+ PropsUi.setLook(wSettings);
+ Image gear = GuiResource.getInstance().getImage("ui/images/gear.svg", 16,
16);
+ if (gear != null) {
+ wSettings.setImage(gear);
+ } else {
+ wSettings.setText("…");
+ }
+ wSettings.setToolTipText(
+ BaseMessages.getString(PKG,
"HopGuiSearchResultsPanel.Settings.Tooltip"));
+ wSettings.addListener(
+ SWT.Selection,
+ e -> {
+ if (new SearchSettingsDialog(getShell(), hopGui).open()) {
+ invalidateCache();
+ search(new Event());
+ }
+ });
+
+ wlStatus = new Label(toolbar, SWT.LEFT);
+ PropsUi.setLook(wlStatus);
+ wlStatus.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+
// --- Bottom: Open button ---
//
wbOpen = new Button(this, SWT.PUSH);
@@ -316,12 +354,22 @@ public class HopGuiSearchResultsPanel extends Composite {
}
}
+ private void onSearchModified() {
+ if (isDisposed() || suppressAutoSearch) {
+ return;
+ }
+ if (!SearchLimits.fromConfig().isSearchAsYouType()) {
+ return;
+ }
+ scheduleSearch();
+ }
+
private void scheduleSearch() {
if (isDisposed() || suppressAutoSearch) {
return;
}
getDisplay().timerExec(-1, searchRunnable);
- getDisplay().timerExec(SEARCH_DELAY_MS, searchRunnable);
+ getDisplay().timerExec(SearchLimits.fromConfig().getDebounceMs(),
searchRunnable);
}
/** Size the last column (Matched text) to take whatever width is left after
the Name column. */
@@ -402,7 +450,7 @@ public class HopGuiSearchResultsPanel extends Composite {
}
/** Make sure the searchables across all locations are enumerated once (and
cached). */
- private boolean ensureLoaded() {
+ private synchronized boolean ensureLoaded() {
if (cachedEnumeration != null) {
return true;
}
@@ -410,6 +458,9 @@ public class HopGuiSearchResultsPanel extends Composite {
if (cachedAnalysers == null) {
cachedAnalysers = HopGuiSearchHelper.loadSearchableAnalysers();
}
+ if (searchablesLocations == null) {
+ searchablesLocations = hopGui.getSearchablesLocations();
+ }
cachedEnumeration =
HopGuiSearchHelper.enumerateAll(
searchablesLocations,
@@ -434,31 +485,79 @@ public class HopGuiSearchResultsPanel extends Composite {
String searchString = wSearchString.getText();
if (Utils.isEmpty(searchString)) {
+ searchGeneration.incrementAndGet();
+ setStatus("");
return;
}
- if (searchablesLocations == null) {
- searchablesLocations = hopGui.getSearchablesLocations();
- }
- if (!ensureLoaded()) {
- return;
- }
+ final boolean caseSensitive = wCaseSensitive.getSelection();
+ final boolean regExp = wRegEx.getSelection();
+ final SearchLimits limits = SearchLimits.fromConfig();
+ final int generation = searchGeneration.incrementAndGet();
+ final Display display = getDisplay();
+ setStatus(BaseMessages.getString(PKG,
"HopGuiSearchResultsPanel.Status.Searching"));
+
+ searchExecutor.execute(
+ () -> {
+ try {
+ if (!ensureLoaded()) {
+ display.asyncExec(
+ () -> {
+ if (generation == searchGeneration.get() && !isDisposed())
{
+ setStatus("");
+ }
+ });
+ return;
+ }
+ SearchQuery query = new SearchQuery(searchString, caseSensitive,
regExp);
+ SearchAnalysisResult analysis =
+ HopGuiSearchHelper.analyseRankedLimited(
+ cachedEnumeration.getSearchables(),
+ query,
+ cachedAnalysers,
+ true,
+ limits,
+ cachedEnumeration.getSourceByKey());
+ display.asyncExec(
+ () -> {
+ if (generation != searchGeneration.get() || isDisposed()) {
+ return;
+ }
+ populateTree(analysis.getResults(),
cachedEnumeration.getSourceByKey());
+ setStatusFromAnalysis(analysis, limits);
+ });
+ } catch (Exception e) {
+ hopGui.getLog().logError("Error while searching", e);
+ display.asyncExec(
+ () -> {
+ if (generation == searchGeneration.get() && !isDisposed()) {
+ setStatus("");
+ }
+ });
+ }
+ });
+ }
- SearchQuery query =
- new SearchQuery(searchString, wCaseSensitive.getSelection(),
wRegEx.getSelection());
+ private void setStatus(String text) {
+ if (wlStatus != null && !wlStatus.isDisposed()) {
+ wlStatus.setText(Const.NVL(text, ""));
+ }
+ }
- // 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<>();
+ private void setStatusFromAnalysis(SearchAnalysisResult analysis,
SearchLimits limits) {
+ if (analysis.isTruncated()) {
+ setStatus(BaseMessages.getString(PKG,
"HopGuiSearchResultsPanel.Status.Capped"));
+ } else if (analysis.isContentSearchSkipped()) {
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ "HopGuiSearchResultsPanel.Status.MinLength",
+ Integer.toString(limits.getMinContentQueryLength())));
+ } else if (analysis.isProjectTextFilesExcluded()) {
+ setStatus(BaseMessages.getString(PKG,
"HopGuiSearchResultsPanel.Status.TextFilesExcluded"));
+ } else {
+ setStatus("");
}
- populateTree(results, cachedEnumeration.getSourceByKey());
}
/**
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
index efce59d17f..a6c6ccae40 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchEverywhereDialog.java
@@ -22,6 +22,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.hop.core.Const;
import org.apache.hop.core.search.ISearchResult;
import org.apache.hop.core.search.ISearchable;
@@ -35,6 +38,8 @@ 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.search.config.SearchAnalysisResult;
+import org.apache.hop.ui.hopgui.search.config.SearchLimits;
import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
import org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock;
import org.eclipse.swt.SWT;
@@ -70,9 +75,6 @@ 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.
*/
@@ -101,6 +103,7 @@ public class SearchEverywhereDialog {
private Combo wSearch;
private Button wCaseSensitive;
private Button wRegEx;
+ private Button wSettings;
private Tree wTree;
private TreeColumn nameColumn;
private TreeColumn detailColumn;
@@ -109,6 +112,16 @@ public class SearchEverywhereDialog {
private Color detailColor;
private final Runnable searchRunnable = this::runSearch;
private int fullResultCount;
+ private SearchAnalysisResult lastAnalysis = SearchAnalysisResult.empty();
+
+ private final AtomicInteger searchGeneration = new AtomicInteger();
+ private final ExecutorService searchExecutor =
+ Executors.newSingleThreadExecutor(
+ r -> {
+ Thread t = new Thread(r, "hop-gui-search");
+ t.setDaemon(true);
+ return t;
+ });
public SearchEverywhereDialog(Shell parent, HopGui hopGui) {
this.parent = parent;
@@ -131,10 +144,10 @@ public class SearchEverywhereDialog {
int margin = PropsUi.getMargin();
- // --- Search bar: text field + case / regex toggles ---
+ // --- Search bar: text field + case / regex toggles + settings ---
Composite searchBar = new Composite(shell, SWT.NONE);
PropsUi.setLook(searchBar);
- GridLayout gridLayout = new GridLayout(3, false);
+ GridLayout gridLayout = new GridLayout(4, false);
gridLayout.marginWidth = 0;
gridLayout.horizontalSpacing = margin;
searchBar.setLayout(gridLayout);
@@ -158,11 +171,25 @@ public class SearchEverywhereDialog {
wCaseSensitive.setToolTipText(
BaseMessages.getString(PKG,
"SearchEverywhereDialog.CaseSensitive.Tooltip"));
PropsUi.setLook(wCaseSensitive);
+ wCaseSensitive.addListener(SWT.Selection, e -> scheduleSearch());
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);
+ wRegEx.addListener(SWT.Selection, e -> scheduleSearch());
+
+ wSettings = new Button(searchBar, SWT.PUSH);
+ PropsUi.setLook(wSettings);
+ Image gear = GuiResource.getInstance().getImage("ui/images/gear.svg", 16,
16);
+ if (gear != null) {
+ wSettings.setImage(gear);
+ } else {
+ wSettings.setText("…");
+ }
+ wSettings.setToolTipText(
+ BaseMessages.getString(PKG,
"SearchEverywhereDialog.Settings.Tooltip"));
+ wSettings.addListener(SWT.Selection, e -> openSettings());
// --- Footer: hint + "show all" handoff to the search perspective ---
wShowAll = new Link(shell, SWT.NONE);
@@ -201,7 +228,7 @@ public class SearchEverywhereDialog {
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.Modify, e -> onSearchModified());
wSearch.addListener(SWT.KeyDown, this::handleSearchKey);
// Close on Escape or via the window close button (both persist the window
size).
@@ -234,6 +261,29 @@ public class SearchEverywhereDialog {
}
}
+ private void onSearchModified() {
+ SearchLimits limits = SearchLimits.fromConfig();
+ if (!limits.isSearchAsYouType()) {
+ return;
+ }
+ scheduleSearch();
+ }
+
+ private void openSettings() {
+ boolean saved = new SearchSettingsDialog(shell, hopGui).open();
+ if (saved) {
+ // Size / include-text-file settings may change what is loadable.
+ invalidateCache();
+ scheduleSearch();
+ }
+ }
+
+ private void invalidateCache() {
+ cachedSearchables = null;
+ cachedAnalysers = null;
+ sourceByKey.clear();
+ }
+
/** Restore the previously remembered size/position, or center a sensible
default. */
private void restoreSize() {
shell.setMinimumSize(MIN_WIDTH, MIN_HEIGHT);
@@ -291,9 +341,13 @@ public class SearchEverywhereDialog {
}
private void scheduleSearch() {
+ if (shell == null || shell.isDisposed()) {
+ return;
+ }
Display display = shell.getDisplay();
display.timerExec(-1, searchRunnable);
- display.timerExec(SEARCH_DELAY_MS, searchRunnable);
+ int delay = SearchLimits.fromConfig().getDebounceMs();
+ display.timerExec(delay, searchRunnable);
}
private void handleSearchKey(Event event) {
@@ -309,7 +363,10 @@ public class SearchEverywhereDialog {
case SWT.CR:
case SWT.KEYPAD_CR:
event.doit = false;
- openSelected();
+ // Enter always runs a search immediately (even when
search-as-you-type is off).
+ Display display = shell.getDisplay();
+ display.timerExec(-1, searchRunnable);
+ runSearch();
break;
default:
// let the keystroke reach the text field
@@ -366,7 +423,7 @@ public class SearchEverywhereDialog {
* de-duplicating objects that several locations report (e.g. metadata and
variables are listed by
* both the GUI and the project location). Subsequent keystrokes only
re-match against this cache.
*/
- private void ensureLoaded() {
+ private synchronized void ensureLoaded() {
if (cachedSearchables != null) {
return;
}
@@ -391,39 +448,74 @@ public class SearchEverywhereDialog {
if (shell == null || shell.isDisposed()) {
return;
}
- wTree.removeAll();
- fullResultCount = 0;
String searchString = wSearch.getText();
if (Utils.isEmpty(searchString)) {
+ searchGeneration.incrementAndGet();
+ wTree.removeAll();
+ fullResultCount = 0;
+ lastAnalysis = SearchAnalysisResult.empty();
updateShowAll();
return;
}
- ensureLoaded();
+ final boolean caseSensitive = wCaseSensitive.getSelection();
+ final boolean regExp = wRegEx.getSelection();
+ final SearchLimits limits = SearchLimits.fromConfig();
+ final int generation = searchGeneration.incrementAndGet();
+ final Display display = shell.getDisplay();
- boolean caseSensitive = wCaseSensitive.getSelection();
- boolean regExp = wRegEx.getSelection();
- SearchQuery query = new SearchQuery(searchString, caseSensitive, regExp);
+ wTree.removeAll();
+ fullResultCount = 0;
+ updateShowAllSearching();
+
+ searchExecutor.execute(
+ () -> {
+ try {
+ ensureLoaded();
+ SearchQuery query = new SearchQuery(searchString, caseSensitive,
regExp);
+ SearchAnalysisResult analysis =
+ HopGuiSearchHelper.analyseRankedLimited(
+ cachedSearchables, query, cachedAnalysers, true, limits,
sourceByKey);
+ display.asyncExec(
+ () -> {
+ if (generation != searchGeneration.get() || shell == null ||
shell.isDisposed()) {
+ return;
+ }
+ paintResults(analysis, searchString, regExp, caseSensitive,
limits);
+ });
+ } catch (Exception e) {
+ hopGui.getLog().logError("Error while searching", e);
+ display.asyncExec(
+ () -> {
+ if (generation != searchGeneration.get() || shell == null ||
shell.isDisposed()) {
+ return;
+ }
+ lastAnalysis = SearchAnalysisResult.empty();
+ updateShowAll();
+ });
+ }
+ });
+ }
- // 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<>();
- }
+ private void paintResults(
+ SearchAnalysisResult analysis,
+ String searchString,
+ boolean regExp,
+ boolean caseSensitive,
+ SearchLimits limits) {
+ lastAnalysis = analysis;
+ wTree.removeAll();
+ fullResultCount = 0;
- // 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)) {
+ HopGuiSearchHelper.groupResults(analysis.getResults(), sourceByKey)) {
addSection(section, searchTerm);
}
- addCommandGroup(searchString, caseSensitive);
+ if (!analysis.isTruncated()) {
+ addCommandGroup(searchString, caseSensitive, limits);
+ }
selectFirstLeaf();
updateShowAll();
@@ -485,7 +577,7 @@ public class SearchEverywhereDialog {
: HopGuiSearchHelper.sanitizePath(detail, hopGui.getVariables());
}
- private void addCommandGroup(String searchString, boolean caseSensitive) {
+ private void addCommandGroup(String searchString, boolean caseSensitive,
SearchLimits limits) {
GuiMenuWidgets menuWidgets = hopGui.getMainMenuWidgets();
if (menuWidgets == null) {
return;
@@ -557,11 +649,37 @@ public class SearchEverywhereDialog {
}
}
+ private void updateShowAllSearching() {
+ wShowAll.setText(BaseMessages.getString(PKG,
"SearchEverywhereDialog.Searching.Label"));
+ wShowAll.requestLayout();
+ }
+
private void updateShowAll() {
if (fullResultCount > 0) {
+ String base =
+ BaseMessages.getString(
+ PKG, "SearchEverywhereDialog.ShowAll.Label",
Integer.toString(fullResultCount));
+ if (lastAnalysis.isTruncated()) {
+ base = base + " — " + BaseMessages.getString(PKG,
"SearchEverywhereDialog.Status.Capped");
+ } else if (lastAnalysis.isContentSearchSkipped()) {
+ base =
+ base
+ + " — "
+ + BaseMessages.getString(
+ PKG,
+ "SearchEverywhereDialog.Status.MinLength",
+
Integer.toString(SearchLimits.fromConfig().getMinContentQueryLength()));
+ }
+ wShowAll.setText(base);
+ } else if (lastAnalysis.isContentSearchSkipped()
+ && wSearch != null
+ && !wSearch.isDisposed()
+ && !Utils.isEmpty(wSearch.getText())) {
wShowAll.setText(
BaseMessages.getString(
- PKG, "SearchEverywhereDialog.ShowAll.Label",
Integer.toString(fullResultCount)));
+ PKG,
+ "SearchEverywhereDialog.Status.MinLengthOnly",
+
Integer.toString(SearchLimits.fromConfig().getMinContentQueryLength())));
} else {
wShowAll.setText(BaseMessages.getString(PKG,
"SearchEverywhereDialog.Hint.Label"));
}
@@ -667,6 +785,8 @@ public class SearchEverywhereDialog {
}
private void dispose() {
+ searchGeneration.incrementAndGet();
+ searchExecutor.shutdownNow();
if (shell != null && !shell.isDisposed()) {
// Remember the size and position for next time.
props.setScreen(new WindowProperty(shell));
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchSettingsDialog.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchSettingsDialog.java
new file mode 100644
index 0000000000..c373a38fc6
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/SearchSettingsDialog.java
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.search;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.search.config.SearchConfig;
+import org.apache.hop.ui.hopgui.search.config.SearchConfigSingleton;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+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.Control;
+import org.eclipse.swt.widgets.Dialog;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+/** Compact settings dialog for Hop GUI search limits (also available in
Configuration). */
+public class SearchSettingsDialog extends Dialog {
+
+ private static final Class<?> PKG = SearchEverywhereDialog.class; // shared
search messages
+
+ private final Shell parent;
+ private final HopGui hopGui;
+ private final PropsUi props = PropsUi.getInstance();
+ private Shell shell;
+
+ private TextVar wMinLength;
+ private TextVar wMaxResults;
+ private TextVar wMaxPerFile;
+ private TextVar wMaxSizeMb;
+ private Button wIncludeTextFiles;
+ private Button wSearchAsYouType;
+ private TextVar wDebounceMs;
+
+ private boolean saved;
+
+ public SearchSettingsDialog(Shell parent, HopGui hopGui) {
+ super(parent, SWT.NONE);
+ this.parent = parent;
+ this.hopGui = hopGui;
+ }
+
+ /**
+ * Open the dialog.
+ *
+ * @return true if the user saved changes
+ */
+ public boolean open() {
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE |
SWT.APPLICATION_MODAL | SWT.SHEET);
+ shell.setText(BaseMessages.getString(PKG,
"SearchSettingsDialog.Shell.Title"));
+ shell.setImage(GuiResource.getInstance().getImage("ui/images/gear.svg",
16, 16));
+ PropsUi.setLook(shell);
+
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ shell.setLayout(formLayout);
+
+ int margin = PropsUi.getMargin();
+ int middle = props.getMiddlePct();
+
+ wMinLength =
+ addTextField(
+ "SearchSettingsDialog.MinContentQueryLength.Label",
+ "SearchSettingsDialog.MinContentQueryLength.Tooltip",
+ middle,
+ margin,
+ null);
+ wMaxResults =
+ addTextField(
+ "SearchSettingsDialog.MaxResults.Label",
+ "SearchSettingsDialog.MaxResults.Tooltip",
+ middle,
+ margin,
+ wMinLength);
+ wMaxPerFile =
+ addTextField(
+ "SearchSettingsDialog.MaxMatchesPerFile.Label",
+ "SearchSettingsDialog.MaxMatchesPerFile.Tooltip",
+ middle,
+ margin,
+ wMaxResults);
+ wMaxSizeMb =
+ addTextField(
+ "SearchSettingsDialog.MaxTextFileSizeMb.Label",
+ "SearchSettingsDialog.MaxTextFileSizeMb.Tooltip",
+ middle,
+ margin,
+ wMaxPerFile);
+ wDebounceMs =
+ addTextField(
+ "SearchSettingsDialog.DebounceMs.Label",
+ "SearchSettingsDialog.DebounceMs.Tooltip",
+ middle,
+ margin,
+ wMaxSizeMb);
+
+ wIncludeTextFiles = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wIncludeTextFiles);
+ wIncludeTextFiles.setText(
+ BaseMessages.getString(PKG,
"SearchSettingsDialog.IncludeProjectTextFiles.Label"));
+ wIncludeTextFiles.setToolTipText(
+ BaseMessages.getString(PKG,
"SearchSettingsDialog.IncludeProjectTextFiles.Tooltip"));
+ FormData fdInclude = new FormData();
+ fdInclude.left = new FormAttachment(0, 0);
+ fdInclude.right = new FormAttachment(100, 0);
+ fdInclude.top = new FormAttachment(wDebounceMs, margin * 2);
+ wIncludeTextFiles.setLayoutData(fdInclude);
+
+ wSearchAsYouType = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wSearchAsYouType);
+ wSearchAsYouType.setText(
+ BaseMessages.getString(PKG,
"SearchSettingsDialog.SearchAsYouType.Label"));
+ wSearchAsYouType.setToolTipText(
+ BaseMessages.getString(PKG,
"SearchSettingsDialog.SearchAsYouType.Tooltip"));
+ FormData fdAsYouType = new FormData();
+ fdAsYouType.left = new FormAttachment(0, 0);
+ fdAsYouType.right = new FormAttachment(100, 0);
+ fdAsYouType.top = new FormAttachment(wIncludeTextFiles, margin);
+ wSearchAsYouType.setLayoutData(fdAsYouType);
+
+ Button wOk = new Button(shell, SWT.PUSH);
+ wOk.setText(BaseMessages.getString(PKG, "System.Button.OK"));
+ wOk.addListener(SWT.Selection, e -> ok());
+
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+ wCancel.addListener(SWT.Selection, e -> cancel());
+
+ BaseTransformDialog.positionBottomButtons(
+ shell, new Button[] {wOk, wCancel}, margin, wSearchAsYouType);
+
+ loadValues();
+
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return saved;
+ }
+
+ private TextVar addTextField(
+ String labelKey, String tooltipKey, int middle, int margin, Control
last) {
+ Label label = new Label(shell, SWT.RIGHT);
+ PropsUi.setLook(label);
+ label.setText(BaseMessages.getString(PKG, labelKey));
+ label.setToolTipText(BaseMessages.getString(PKG, tooltipKey));
+ FormData fdLabel = new FormData();
+ fdLabel.left = new FormAttachment(0, 0);
+ fdLabel.right = new FormAttachment(middle, -margin);
+ fdLabel.top = new FormAttachment(last, last == null ? 0 : margin);
+ label.setLayoutData(fdLabel);
+
+ TextVar field = new TextVar(hopGui.getVariables(), shell, SWT.SINGLE |
SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(field);
+ field.setToolTipText(BaseMessages.getString(PKG, tooltipKey));
+ FormData fdField = new FormData();
+ fdField.left = new FormAttachment(middle, 0);
+ fdField.right = new FormAttachment(100, 0);
+ fdField.top = new FormAttachment(label, 0, SWT.CENTER);
+ field.setLayoutData(fdField);
+ return field;
+ }
+
+ private void loadValues() {
+ SearchConfig config = SearchConfigSingleton.getConfig();
+ wMinLength.setText(
+ Const.NVL(
+ config.getMinContentQueryLength(),
SearchConfig.DEFAULT_MIN_CONTENT_QUERY_LENGTH));
+ wMaxResults.setText(Const.NVL(config.getMaxResults(),
SearchConfig.DEFAULT_MAX_RESULTS));
+ wMaxPerFile.setText(
+ Const.NVL(config.getMaxMatchesPerFile(),
SearchConfig.DEFAULT_MAX_MATCHES_PER_FILE));
+ wMaxSizeMb.setText(
+ Const.NVL(config.getMaxTextFileSizeMb(),
SearchConfig.DEFAULT_MAX_TEXT_FILE_SIZE_MB));
+ wDebounceMs.setText(Const.NVL(config.getDebounceMs(),
SearchConfig.DEFAULT_DEBOUNCE_MS));
+ wIncludeTextFiles.setSelection(
+ config.getIncludeProjectTextFiles() == null ||
config.getIncludeProjectTextFiles());
+ wSearchAsYouType.setSelection(
+ config.getSearchAsYouType() == null || config.getSearchAsYouType());
+ }
+
+ private void ok() {
+ SearchConfig config = SearchConfigSingleton.getConfig();
+ config.setMinContentQueryLength(wMinLength.getText());
+ config.setMaxResults(wMaxResults.getText());
+ config.setMaxMatchesPerFile(wMaxPerFile.getText());
+ config.setMaxTextFileSizeMb(wMaxSizeMb.getText());
+ config.setDebounceMs(wDebounceMs.getText());
+ config.setIncludeProjectTextFiles(wIncludeTextFiles.getSelection());
+ config.setSearchAsYouType(wSearchAsYouType.getSelection());
+ try {
+ SearchConfigSingleton.saveConfig();
+ saved = true;
+ dispose();
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG, "SearchSettingsDialog.Error.Title"),
+ BaseMessages.getString(PKG,
"SearchSettingsDialog.Error.Save.Message"),
+ e);
+ }
+ }
+
+ private void cancel() {
+ saved = false;
+ dispose();
+ }
+
+ private void dispose() {
+ if (shell != null && !shell.isDisposed()) {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
index d75f8efb52..11b5a8d320 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyser.java
@@ -25,6 +25,7 @@ import org.apache.hop.core.search.ISearchResult;
import org.apache.hop.core.search.ISearchable;
import org.apache.hop.core.search.ISearchableAnalyser;
import org.apache.hop.core.search.SearchableAnalyserPlugin;
+import org.apache.hop.ui.hopgui.search.config.SearchLimits;
@SearchableAnalyserPlugin(
id = "TextFileContentSearchableAnalyser",
@@ -49,11 +50,31 @@ public class TextFileContentSearchableAnalyser extends
BaseSearchableAnalyser<Te
return results;
}
- String[] lines = content.split("\\R", -1);
- for (int i = 0; i < lines.length; i++) {
- String line = lines[i];
- String lineNumber = String.valueOf(i + 1);
- matchProperty(searchable, results, searchQuery, "line " + lineNumber,
line, lineNumber);
+ int maxMatches = SearchLimits.fromConfig().getMaxMatchesPerFile();
+
+ // Stream line-by-line without allocating a full String[] for huge files.
+ int lineNumber = 0;
+ int start = 0;
+ final int length = content.length();
+ while (start <= length) {
+ int end = content.indexOf('\n', start);
+ if (end < 0) {
+ end = length;
+ }
+ String line = content.substring(start, end);
+ if (!line.isEmpty() && line.charAt(line.length() - 1) == '\r') {
+ line = line.substring(0, line.length() - 1);
+ }
+ lineNumber++;
+ matchProperty(
+ searchable, results, searchQuery, "line " + lineNumber, line,
String.valueOf(lineNumber));
+ if (results.size() >= maxMatches) {
+ break;
+ }
+ if (end == length) {
+ break;
+ }
+ start = end + 1;
}
return results;
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchAnalysisResult.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchAnalysisResult.java
new file mode 100644
index 0000000000..e76b574283
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchAnalysisResult.java
@@ -0,0 +1,48 @@
+/*
+ * 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.config;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import org.apache.hop.core.search.ISearchResult;
+
+/** Outcome of a limited GUI search: results plus whether caps/filters
applied. */
+@Getter
+public class SearchAnalysisResult {
+
+ private final List<ISearchResult> results;
+ private final boolean truncated;
+ private final boolean contentSearchSkipped;
+ private final boolean projectTextFilesExcluded;
+
+ public SearchAnalysisResult(
+ List<ISearchResult> results,
+ boolean truncated,
+ boolean contentSearchSkipped,
+ boolean projectTextFilesExcluded) {
+ this.results = results == null ? new ArrayList<>() : results;
+ this.truncated = truncated;
+ this.contentSearchSkipped = contentSearchSkipped;
+ this.projectTextFilesExcluded = projectTextFilesExcluded;
+ }
+
+ public static SearchAnalysisResult empty() {
+ return new SearchAnalysisResult(List.of(), false, false, false);
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfig.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfig.java
new file mode 100644
index 0000000000..753d7aed6e
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfig.java
@@ -0,0 +1,90 @@
+/*
+ * 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.config;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Persistent Hop GUI search settings. Stored under {@link
#HOP_CONFIG_SEARCH_KEY} in hop-config.
+ *
+ * <p>Large data files (million-row CSV/JSON, …) are intentionally excluded
from GUI search via
+ * {@link #maxTextFileSizeMb}; use dedicated tools for those.
+ */
+@Getter
+@Setter
+public class SearchConfig {
+
+ public static final String HOP_CONFIG_SEARCH_KEY = "search";
+
+ public static final String DEFAULT_MIN_CONTENT_QUERY_LENGTH = "3";
+ public static final String DEFAULT_MAX_RESULTS = "500";
+ public static final String DEFAULT_MAX_MATCHES_PER_FILE = "20";
+ public static final String DEFAULT_MAX_TEXT_FILE_SIZE_MB = "1";
+ public static final String DEFAULT_DEBOUNCE_MS = "300";
+
+ /** Minimum characters before file/content matching runs (names still match
below this). */
+ private String minContentQueryLength;
+
+ /** Hard cap on total search results built during analysis (GUI only). */
+ private String maxResults;
+
+ /** Cap on content matches reported per text file. */
+ private String maxMatchesPerFile;
+
+ /**
+ * Maximum size in megabytes of a text file that may be loaded for content
search. Larger files
+ * are skipped entirely.
+ */
+ private String maxTextFileSizeMb;
+
+ /**
+ * When false, project text files (CSV, JSON, …) are not content-searched;
open files still are.
+ */
+ private Boolean includeProjectTextFiles;
+
+ /** When true, search runs while typing (debounced); Enter always searches.
*/
+ private Boolean searchAsYouType;
+
+ /** Debounce in milliseconds between keystrokes and running a live search. */
+ private String debounceMs;
+
+ public SearchConfig() {
+ this.minContentQueryLength = DEFAULT_MIN_CONTENT_QUERY_LENGTH;
+ this.maxResults = DEFAULT_MAX_RESULTS;
+ this.maxMatchesPerFile = DEFAULT_MAX_MATCHES_PER_FILE;
+ this.maxTextFileSizeMb = DEFAULT_MAX_TEXT_FILE_SIZE_MB;
+ this.includeProjectTextFiles = true;
+ this.searchAsYouType = true;
+ this.debounceMs = DEFAULT_DEBOUNCE_MS;
+ }
+
+ public SearchConfig(SearchConfig other) {
+ this();
+ if (other == null) {
+ return;
+ }
+ this.minContentQueryLength = other.minContentQueryLength;
+ this.maxResults = other.maxResults;
+ this.maxMatchesPerFile = other.maxMatchesPerFile;
+ this.maxTextFileSizeMb = other.maxTextFileSizeMb;
+ this.includeProjectTextFiles = other.includeProjectTextFiles;
+ this.searchAsYouType = other.searchAsYouType;
+ this.debounceMs = other.debounceMs;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfigPlugin.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfigPlugin.java
new file mode 100644
index 0000000000..6693c652d4
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfigPlugin.java
@@ -0,0 +1,270 @@
+/*
+ * 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.config;
+
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.HopGui;
+import
org.apache.hop.ui.hopgui.perspective.configuration.tabs.ConfigPluginOptionsTab;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import picocli.CommandLine;
+
+@ConfigPlugin(
+ id = "SearchConfigPlugin",
+ description = "Configuration options for Hop GUI search",
+ category = ConfigPlugin.CATEGORY_CONFIG)
+@GuiPlugin(
+ description = "Search" // Tab label in options dialog
+ )
+public class SearchConfigPlugin implements IConfigOptions,
IGuiPluginCompositeWidgetsListener {
+
+ private static final String WIDGET_ID_MIN_CONTENT_QUERY_LENGTH =
+ "20000-search-min-content-query-length";
+ private static final String WIDGET_ID_MAX_RESULTS =
"20010-search-max-results";
+ private static final String WIDGET_ID_MAX_MATCHES_PER_FILE =
"20020-search-max-matches-per-file";
+ private static final String WIDGET_ID_MAX_TEXT_FILE_SIZE_MB =
+ "20030-search-max-text-file-size-mb";
+ private static final String WIDGET_ID_INCLUDE_PROJECT_TEXT_FILES =
+ "20040-search-include-project-text-files";
+ private static final String WIDGET_ID_SEARCH_AS_YOU_TYPE =
"20050-search-as-you-type";
+ private static final String WIDGET_ID_DEBOUNCE_MS =
"20060-search-debounce-ms";
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_MIN_CONTENT_QUERY_LENGTH,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.TEXT,
+ variables = true,
+ label = "i18n::SearchConfig.MinContentQueryLength.Label",
+ toolTip = "i18n::SearchConfig.MinContentQueryLength.Tooltip")
+ @CommandLine.Option(
+ names = {"-smin", "--search-min-content-query-length"},
+ description = "Minimum characters before content search runs in Hop GUI")
+ private String minContentQueryLength;
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_MAX_RESULTS,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.TEXT,
+ variables = true,
+ label = "i18n::SearchConfig.MaxResults.Label",
+ toolTip = "i18n::SearchConfig.MaxResults.Tooltip")
+ @CommandLine.Option(
+ names = {"-smax", "--search-max-results"},
+ description = "Maximum number of search results built in Hop GUI")
+ private String maxResults;
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_MAX_MATCHES_PER_FILE,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.TEXT,
+ variables = true,
+ label = "i18n::SearchConfig.MaxMatchesPerFile.Label",
+ toolTip = "i18n::SearchConfig.MaxMatchesPerFile.Tooltip")
+ @CommandLine.Option(
+ names = {"-smpf", "--search-max-matches-per-file"},
+ description = "Maximum content matches per text file in Hop GUI search")
+ private String maxMatchesPerFile;
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_MAX_TEXT_FILE_SIZE_MB,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.TEXT,
+ variables = true,
+ label = "i18n::SearchConfig.MaxTextFileSizeMb.Label",
+ toolTip = "i18n::SearchConfig.MaxTextFileSizeMb.Tooltip")
+ @CommandLine.Option(
+ names = {"-sfs", "--search-max-text-file-size-mb"},
+ description = "Skip text files larger than this many MB in Hop GUI
search")
+ private String maxTextFileSizeMb;
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_INCLUDE_PROJECT_TEXT_FILES,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.CHECKBOX,
+ label = "i18n::SearchConfig.IncludeProjectTextFiles.Label",
+ toolTip = "i18n::SearchConfig.IncludeProjectTextFiles.Tooltip")
+ @CommandLine.Option(
+ names = {"-sit", "--search-include-project-text-files"},
+ description = "Include project text files (CSV, JSON, …) in Hop GUI
content search",
+ negatable = true)
+ private Boolean includeProjectTextFiles = true;
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_SEARCH_AS_YOU_TYPE,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.CHECKBOX,
+ label = "i18n::SearchConfig.SearchAsYouType.Label",
+ toolTip = "i18n::SearchConfig.SearchAsYouType.Tooltip")
+ @CommandLine.Option(
+ names = {"-sat", "--search-as-you-type"},
+ description = "Run Hop GUI search while typing (debounced)",
+ negatable = true)
+ private Boolean searchAsYouType = true;
+
+ @GuiWidgetElement(
+ id = WIDGET_ID_DEBOUNCE_MS,
+ parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+ type = GuiElementType.TEXT,
+ variables = true,
+ label = "i18n::SearchConfig.DebounceMs.Label",
+ toolTip = "i18n::SearchConfig.DebounceMs.Tooltip")
+ @CommandLine.Option(
+ names = {"-sdb", "--search-debounce-ms"},
+ description = "Debounce in ms between keystrokes and Hop GUI live
search")
+ private String debounceMs;
+
+ public static SearchConfigPlugin getInstance() {
+ SearchConfigPlugin instance = new SearchConfigPlugin();
+ SearchConfig config = SearchConfigSingleton.getConfig();
+ instance.minContentQueryLength = config.getMinContentQueryLength();
+ instance.maxResults = config.getMaxResults();
+ instance.maxMatchesPerFile = config.getMaxMatchesPerFile();
+ instance.maxTextFileSizeMb = config.getMaxTextFileSizeMb();
+ instance.includeProjectTextFiles =
+ config.getIncludeProjectTextFiles() == null ||
config.getIncludeProjectTextFiles();
+ instance.searchAsYouType = config.getSearchAsYouType() == null ||
config.getSearchAsYouType();
+ instance.debounceMs = config.getDebounceMs();
+ return instance;
+ }
+
+ @Override
+ public boolean handleOption(
+ ILogChannel log, IHasHopMetadataProvider hasHopMetadataProvider,
IVariables variables)
+ throws HopException {
+ SearchConfig config = SearchConfigSingleton.getConfig();
+ try {
+ boolean changed = false;
+
+ if (minContentQueryLength != null) {
+ config.setMinContentQueryLength(minContentQueryLength);
+ log.logBasic("Search: min content query length set to '" +
minContentQueryLength + "'");
+ changed = true;
+ }
+ if (maxResults != null) {
+ config.setMaxResults(maxResults);
+ log.logBasic("Search: max results set to '" + maxResults + "'");
+ changed = true;
+ }
+ if (maxMatchesPerFile != null) {
+ config.setMaxMatchesPerFile(maxMatchesPerFile);
+ log.logBasic("Search: max matches per file set to '" +
maxMatchesPerFile + "'");
+ changed = true;
+ }
+ if (maxTextFileSizeMb != null) {
+ config.setMaxTextFileSizeMb(maxTextFileSizeMb);
+ log.logBasic("Search: max text file size (MB) set to '" +
maxTextFileSizeMb + "'");
+ changed = true;
+ }
+ if (includeProjectTextFiles != null) {
+ config.setIncludeProjectTextFiles(includeProjectTextFiles);
+ log.logBasic("Search: include project text files set to '" +
includeProjectTextFiles + "'");
+ changed = true;
+ }
+ if (searchAsYouType != null) {
+ config.setSearchAsYouType(searchAsYouType);
+ log.logBasic("Search: search as you type set to '" + searchAsYouType +
"'");
+ changed = true;
+ }
+ if (debounceMs != null) {
+ config.setDebounceMs(debounceMs);
+ log.logBasic("Search: debounce ms set to '" + debounceMs + "'");
+ changed = true;
+ }
+
+ if (changed) {
+ SearchConfigSingleton.saveConfig();
+ }
+ return changed;
+ } catch (Exception e) {
+ throw new HopException("Error handling search configuration options", e);
+ }
+ }
+
+ @Override
+ public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+ // Do nothing
+ }
+
+ @Override
+ public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+ // Do nothing
+ }
+
+ @Override
+ public void widgetModified(
+ GuiCompositeWidgets compositeWidgets, Control changedWidget, String
widgetId) {
+ persistContents(compositeWidgets);
+ }
+
+ @Override
+ public void persistContents(GuiCompositeWidgets compositeWidgets) {
+ SearchConfig config = SearchConfigSingleton.getConfig();
+ for (String widgetId : compositeWidgets.getWidgetsMap().keySet()) {
+ Control control = compositeWidgets.getWidgetsMap().get(widgetId);
+ switch (widgetId) {
+ case WIDGET_ID_MIN_CONTENT_QUERY_LENGTH:
+ minContentQueryLength = ((TextVar) control).getText();
+ config.setMinContentQueryLength(minContentQueryLength);
+ break;
+ case WIDGET_ID_MAX_RESULTS:
+ maxResults = ((TextVar) control).getText();
+ config.setMaxResults(maxResults);
+ break;
+ case WIDGET_ID_MAX_MATCHES_PER_FILE:
+ maxMatchesPerFile = ((TextVar) control).getText();
+ config.setMaxMatchesPerFile(maxMatchesPerFile);
+ break;
+ case WIDGET_ID_MAX_TEXT_FILE_SIZE_MB:
+ maxTextFileSizeMb = ((TextVar) control).getText();
+ config.setMaxTextFileSizeMb(maxTextFileSizeMb);
+ break;
+ case WIDGET_ID_INCLUDE_PROJECT_TEXT_FILES:
+ includeProjectTextFiles = ((Button) control).getSelection();
+ config.setIncludeProjectTextFiles(includeProjectTextFiles);
+ break;
+ case WIDGET_ID_SEARCH_AS_YOU_TYPE:
+ searchAsYouType = ((Button) control).getSelection();
+ config.setSearchAsYouType(searchAsYouType);
+ break;
+ case WIDGET_ID_DEBOUNCE_MS:
+ debounceMs = ((TextVar) control).getText();
+ config.setDebounceMs(debounceMs);
+ break;
+ default:
+ break;
+ }
+ }
+ try {
+ SearchConfigSingleton.saveConfig();
+ } catch (Exception e) {
+ new ErrorDialog(HopGui.getInstance().getShell(), "Error", "Error saving
search options", e);
+ }
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfigSingleton.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfigSingleton.java
new file mode 100644
index 0000000000..a6469bfe84
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchConfigSingleton.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.search.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.Gson;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.core.logging.LogChannel;
+
+public class SearchConfigSingleton {
+
+ private static SearchConfigSingleton configSingleton;
+
+ private SearchConfig searchConfig;
+
+ private SearchConfigSingleton() {
+ Object configObject =
+
HopConfig.getInstance().getConfigMap().get(SearchConfig.HOP_CONFIG_SEARCH_KEY);
+ if (configObject == null) {
+ searchConfig = new SearchConfig();
+ } else {
+ try {
+ ObjectMapper mapper = HopJson.newMapper();
+ searchConfig = mapper.readValue(new Gson().toJson(configObject),
SearchConfig.class);
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError(
+ "Error reading search configuration, please check property '"
+ + SearchConfig.HOP_CONFIG_SEARCH_KEY
+ + "' in the Hop config json file",
+ e);
+ searchConfig = new SearchConfig();
+ }
+ }
+
HopConfig.getInstance().getConfigMap().put(SearchConfig.HOP_CONFIG_SEARCH_KEY,
searchConfig);
+ }
+
+ public static SearchConfig getConfig() {
+ if (configSingleton == null) {
+ configSingleton = new SearchConfigSingleton();
+ }
+ return configSingleton.searchConfig;
+ }
+
+ public static void saveConfig() throws HopException {
+ if (configSingleton == null) {
+ configSingleton = new SearchConfigSingleton();
+ }
+ HopConfig.getInstance()
+ .saveOption(SearchConfig.HOP_CONFIG_SEARCH_KEY,
configSingleton.searchConfig);
+ HopConfig.getInstance().saveToFile();
+ }
+
+ /**
+ * Replace the in-memory config without writing hop-config. Intended for
unit tests; also used
+ * carefully when applying settings that must take effect immediately.
+ */
+ public static void setConfigForTesting(SearchConfig config) {
+ if (configSingleton == null) {
+ // Avoid loading from disk when HopConfig is not initialized in unit
tests.
+ configSingleton = new SearchConfigSingleton(config == null ? new
SearchConfig() : config);
+ return;
+ }
+ configSingleton.searchConfig = config == null ? new SearchConfig() :
config;
+ }
+
+ private SearchConfigSingleton(SearchConfig config) {
+ this.searchConfig = config;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchLimits.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchLimits.java
new file mode 100644
index 0000000000..88e605edb2
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/search/config/SearchLimits.java
@@ -0,0 +1,95 @@
+/*
+ * 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.config;
+
+import lombok.Getter;
+import org.apache.hop.core.Const;
+
+/**
+ * Parsed, immutable snapshot of {@link SearchConfig} for one search run.
Defaults match the product
+ * safety limits so the GUI never attempts million-row data-file content
search.
+ */
+@Getter
+public final class SearchLimits {
+
+ public static final int DEFAULT_MIN_CONTENT_QUERY_LENGTH =
+ Integer.parseInt(SearchConfig.DEFAULT_MIN_CONTENT_QUERY_LENGTH);
+ public static final int DEFAULT_MAX_RESULTS =
Integer.parseInt(SearchConfig.DEFAULT_MAX_RESULTS);
+ public static final int DEFAULT_MAX_MATCHES_PER_FILE =
+ Integer.parseInt(SearchConfig.DEFAULT_MAX_MATCHES_PER_FILE);
+ public static final int DEFAULT_MAX_TEXT_FILE_SIZE_MB =
+ Integer.parseInt(SearchConfig.DEFAULT_MAX_TEXT_FILE_SIZE_MB);
+ public static final int DEFAULT_DEBOUNCE_MS =
Integer.parseInt(SearchConfig.DEFAULT_DEBOUNCE_MS);
+
+ private final int minContentQueryLength;
+ private final int maxResults;
+ private final int maxMatchesPerFile;
+ private final long maxTextFileSizeBytes;
+ private final boolean includeProjectTextFiles;
+ private final boolean searchAsYouType;
+ private final int debounceMs;
+
+ private SearchLimits(
+ int minContentQueryLength,
+ int maxResults,
+ int maxMatchesPerFile,
+ long maxTextFileSizeBytes,
+ boolean includeProjectTextFiles,
+ boolean searchAsYouType,
+ int debounceMs) {
+ this.minContentQueryLength = Math.max(1, minContentQueryLength);
+ this.maxResults = Math.max(1, maxResults);
+ this.maxMatchesPerFile = Math.max(1, maxMatchesPerFile);
+ this.maxTextFileSizeBytes = Math.max(0L, maxTextFileSizeBytes);
+ this.includeProjectTextFiles = includeProjectTextFiles;
+ this.searchAsYouType = searchAsYouType;
+ this.debounceMs = Math.max(0, debounceMs);
+ }
+
+ public static SearchLimits defaults() {
+ return fromConfig(new SearchConfig());
+ }
+
+ public static SearchLimits fromConfig() {
+ return fromConfig(SearchConfigSingleton.getConfig());
+ }
+
+ public static SearchLimits fromConfig(SearchConfig config) {
+ SearchConfig c = config == null ? new SearchConfig() : config;
+ int minLen = (int) Const.toLong(c.getMinContentQueryLength(),
DEFAULT_MIN_CONTENT_QUERY_LENGTH);
+ int maxResults = (int) Const.toLong(c.getMaxResults(),
DEFAULT_MAX_RESULTS);
+ int maxPerFile = (int) Const.toLong(c.getMaxMatchesPerFile(),
DEFAULT_MAX_MATCHES_PER_FILE);
+ int maxMb = (int) Const.toLong(c.getMaxTextFileSizeMb(),
DEFAULT_MAX_TEXT_FILE_SIZE_MB);
+ int debounce = (int) Const.toLong(c.getDebounceMs(), DEFAULT_DEBOUNCE_MS);
+ boolean includeText = c.getIncludeProjectTextFiles() == null ||
c.getIncludeProjectTextFiles();
+ boolean asYouType = c.getSearchAsYouType() == null ||
c.getSearchAsYouType();
+ return new SearchLimits(
+ minLen, maxResults, maxPerFile, maxMb * 1024L * 1024L, includeText,
asYouType, debounce);
+ }
+
+ /**
+ * Whether content (including text-file line matching) is allowed for this
query string. Name
+ * matching is always allowed.
+ */
+ public boolean allowsContentSearch(String query) {
+ if (query == null) {
+ return false;
+ }
+ return query.trim().length() >= minContentQueryLength;
+ }
+}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/search/config/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/config/messages/messages_en_US.properties
new file mode 100644
index 0000000000..6c06b3fb0e
--- /dev/null
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/config/messages/messages_en_US.properties
@@ -0,0 +1,32 @@
+#
+#
+# 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.
+#
+
+SearchConfig.MinContentQueryLength.Label=Minimum characters for content search
+SearchConfig.MinContentQueryLength.Tooltip=Object names are always searched.
File and field content is only searched when the query has at least this many
characters (default 3). Prevents floods of hits while typing.
+SearchConfig.MaxResults.Label=Maximum search results
+SearchConfig.MaxResults.Tooltip=Hard limit on the number of results built for
Hop GUI search (default 500). Further matches are not collected so the UI stays
responsive.
+SearchConfig.MaxMatchesPerFile.Label=Maximum matches per text file
+SearchConfig.MaxMatchesPerFile.Tooltip=Stop scanning a single text file after
this many matching lines (default 20).
+SearchConfig.MaxTextFileSizeMb.Label=Maximum text file size for search (MB)
+SearchConfig.MaxTextFileSizeMb.Tooltip=Text files larger than this are skipped
for content search (default 1 MB). Use dedicated tools for million-row CSV/JSON
files.
+SearchConfig.IncludeProjectTextFiles.Label=Include project text files in
content search
+SearchConfig.IncludeProjectTextFiles.Tooltip=When enabled, CSV, JSON, SQL and
other project text files are content-searched (subject to size and match
limits). Open text files are always eligible. Disable to search only pipelines,
workflows, metadata and open files.
+SearchConfig.SearchAsYouType.Label=Search as you type
+SearchConfig.SearchAsYouType.Tooltip=When enabled, search runs automatically
after a short pause while typing. Press Enter to search immediately either way.
+SearchConfig.DebounceMs.Label=Search-as-you-type delay (ms)
+SearchConfig.DebounceMs.Tooltip=Milliseconds to wait after the last keystroke
before running a live search (default 300).
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
index 170a653cd1..3ab1efed16 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/search/messages/messages_en_US.properties
@@ -22,22 +22,46 @@ SearchEverywhereDialog.CaseSensitive.Label=Aa
SearchEverywhereDialog.CaseSensitive.Tooltip=Case sensitive
SearchEverywhereDialog.RegEx.Label=.*
SearchEverywhereDialog.RegEx.Tooltip=Regular expression
+SearchEverywhereDialog.Settings.Tooltip=Search settings (limits, large file
skip, search-as-you-type)
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.Searching.Label=Searching\u2026
SearchEverywhereDialog.ShowAll.Label=<a>Show all {0} results</a>
SearchEverywhereDialog.ShowAll.TabTitle=Search results
+SearchEverywhereDialog.Status.Capped=results capped (see Search settings)
+SearchEverywhereDialog.Status.MinLength=type {0}+ characters to search file
content
+SearchEverywhereDialog.Status.MinLengthOnly=Type at least {0} characters to
search file content (names still match)
SearchEverywhereDialog.Error.Title=Error
SearchEverywhereDialog.Error.Message=Error while searching
SearchEverywhereDialog.Error.Open.Message=Error opening {0}
+SearchSettingsDialog.Shell.Title=Search settings
+SearchSettingsDialog.MinContentQueryLength.Label=Min. characters for content
+SearchSettingsDialog.MinContentQueryLength.Tooltip=Names always match. Content
(including text files) only when the query is this long.
+SearchSettingsDialog.MaxResults.Label=Maximum results
+SearchSettingsDialog.MaxResults.Tooltip=Hard cap on results collected for Hop
GUI search.
+SearchSettingsDialog.MaxMatchesPerFile.Label=Max. matches per text file
+SearchSettingsDialog.MaxMatchesPerFile.Tooltip=Stop after this many matching
lines in one text file.
+SearchSettingsDialog.MaxTextFileSizeMb.Label=Max. text file size (MB)
+SearchSettingsDialog.MaxTextFileSizeMb.Tooltip=Skip larger text files. Use
dedicated tools for million-row CSV/JSON.
+SearchSettingsDialog.DebounceMs.Label=Search-as-you-type delay (ms)
+SearchSettingsDialog.DebounceMs.Tooltip=Wait this long after typing before
running a live search.
+SearchSettingsDialog.IncludeProjectTextFiles.Label=Include project text files
(CSV, JSON, \u2026)
+SearchSettingsDialog.IncludeProjectTextFiles.Tooltip=When off, only open text
files are content-searched. Pipelines, workflows and metadata are always
searched.
+SearchSettingsDialog.SearchAsYouType.Label=Search as you type
+SearchSettingsDialog.SearchAsYouType.Tooltip=When off, press Enter to search.
Enter always searches immediately either way.
+SearchSettingsDialog.Error.Title=Error
+SearchSettingsDialog.Error.Save.Message=Error saving search settings
+
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.Settings.Tooltip=Search settings (limits, large file
skip, search-as-you-type)
HopGuiSearchResultsPanel.Open.Button.Label=Open
HopGuiSearchResultsPanel.Column.Name=Name
HopGuiSearchResultsPanel.Column.Match=Matched text
@@ -50,5 +74,9 @@ HopGuiSearchResultsPanel.Detail.Location=Location
HopGuiSearchResultsPanel.Detail.Match=Matching Text
HopGuiSearchResultsPanel.Detail.Value=Value
HopGuiSearchResultsPanel.Detail.Description=Description
+HopGuiSearchResultsPanel.Status.Searching=Searching\u2026
+HopGuiSearchResultsPanel.Status.Capped=Results capped (see Search settings)
+HopGuiSearchResultsPanel.Status.MinLength=Type {0}+ characters to search file
content
+HopGuiSearchResultsPanel.Status.TextFilesExcluded=Project text files excluded
from content search
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
index c6328f9b95..39bc3be73a 100644
---
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/HopGuiSearchHelperTest.java
@@ -29,12 +29,18 @@ import java.util.List;
import java.util.Map;
import org.apache.hop.core.search.ISearchResult;
import org.apache.hop.core.search.ISearchable;
+import org.apache.hop.core.search.ISearchableAnalyser;
+import org.apache.hop.core.search.SearchQuery;
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.apache.hop.ui.hopgui.search.config.SearchAnalysisResult;
+import org.apache.hop.ui.hopgui.search.config.SearchConfig;
+import org.apache.hop.ui.hopgui.search.config.SearchConfigSingleton;
+import org.apache.hop.ui.hopgui.search.config.SearchLimits;
import org.junit.jupiter.api.Test;
/** Unit tests for the two-tier grouping that both search windows render. */
@@ -222,4 +228,90 @@ class HopGuiSearchHelperTest {
assertTrue(HopGuiSearchHelper.isNameMatch(nameMatch(open)));
assertFalse(HopGuiSearchHelper.isNameMatch(fieldMatch(open, "Table input",
"select 1")));
}
+
+ @Test
+ void analyseLimitedSkipsTextContentForShortQueryButKeepsNameMatches() {
+ StringBuilder csv = new StringBuilder();
+ for (int i = 0; i < 50; i++) {
+ csv.append("address line ").append(i).append('\n');
+ }
+ ISearchable text = textFile("addresses.csv", "/p/addresses.csv");
+ when(text.getSearchableObject())
+ .thenReturn(new TextFileContent("/p/addresses.csv", csv.toString()));
+ when(text.getName()).thenReturn("addresses.csv");
+
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analysers =
textFileAnalysers();
+
+ SearchConfig config = new SearchConfig();
+ config.setMinContentQueryLength("3");
+ config.setMaxResults("500");
+ SearchLimits limits = SearchLimits.fromConfig(config);
+
+ SearchAnalysisResult shortQuery =
+ HopGuiSearchHelper.analyseRankedLimited(
+ List.of(text),
+ new SearchQuery("a", false, false),
+ analysers,
+ true,
+ limits,
+ source(text, 1));
+ assertTrue(shortQuery.isContentSearchSkipped());
+ // Name "addresses.csv" still matches "a"
+ assertFalse(shortQuery.getResults().isEmpty());
+
assertTrue(shortQuery.getResults().stream().allMatch(HopGuiSearchHelper::isNameMatch));
+
+ SearchAnalysisResult longQuery =
+ HopGuiSearchHelper.analyseRankedLimited(
+ List.of(text),
+ new SearchQuery("address", false, false),
+ analysers,
+ true,
+ limits,
+ source(text, 1));
+ assertFalse(longQuery.isContentSearchSkipped());
+ assertTrue(longQuery.getResults().size() > 1);
+ }
+
+ @Test
+ void analyseLimitedRespectsGlobalMaxResults() {
+ StringBuilder csv = new StringBuilder();
+ for (int i = 0; i < 200; i++) {
+ csv.append("address ").append(i).append('\n');
+ }
+ ISearchable text = textFile("data.csv", "/p/data.csv");
+ when(text.getSearchableObject()).thenReturn(new
TextFileContent("/p/data.csv", csv.toString()));
+
+ Map<Class<ISearchableAnalyser>, ISearchableAnalyser> analysers =
textFileAnalysers();
+
+ SearchConfig config = new SearchConfig();
+ config.setMinContentQueryLength("1");
+ config.setMaxResults("15");
+ config.setMaxMatchesPerFile("100");
+ SearchLimits limits = SearchLimits.fromConfig(config);
+
+ // Temporarily raise per-file cap via config used by the analyser
+ SearchConfigSingleton.setConfigForTesting(config);
+ try {
+ SearchAnalysisResult analysis =
+ HopGuiSearchHelper.analyseRankedLimited(
+ List.of(text),
+ new SearchQuery("address", false, false),
+ analysers,
+ false,
+ limits,
+ source(text, 1));
+ assertTrue(analysis.isTruncated() || analysis.getResults().size() <= 15);
+ assertTrue(analysis.getResults().size() <= 15);
+ } finally {
+ SearchConfigSingleton.setConfigForTesting(new SearchConfig());
+ }
+ }
+
+ /** Analyser map keys are searchable object classes (not analyser classes).
*/
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static Map<Class<ISearchableAnalyser>, ISearchableAnalyser>
textFileAnalysers() {
+ Map analysers = new HashMap();
+ analysers.put(TextFileContent.class, new
TextFileContentSearchableAnalyser());
+ return analysers;
+ }
}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
index 484bdb8b9d..63b84b67bd 100644
---
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/TextFileContentSearchableAnalyserTest.java
@@ -44,6 +44,24 @@ class TextFileContentSearchableAnalyserTest {
assertTrue(results.get(0).getMatchingString().contains("foo"));
}
+ @Test
+ void capsMatchesPerFileAtConfiguredLimit() {
+ StringBuilder content = new StringBuilder();
+ for (int i = 0; i < 100; i++) {
+ content.append("line with address ").append(i).append('\n');
+ }
+ TextFileContent file = new TextFileContent("/project/data.csv",
content.toString());
+ ISearchable<TextFileContent> searchable =
+ new HopGuiTextFileSearchable("test", "CSV File", file);
+
+ TextFileContentSearchableAnalyser analyser = new
TextFileContentSearchableAnalyser();
+ List<ISearchResult> results =
+ analyser.search(searchable, new SearchQuery("address", false, false));
+
+ // Default max matches per file is 20 — must not report all 100 synthetic
lines.
+ assertEquals(20, results.size());
+ }
+
@Test
void emptyContentReturnsNoResults() {
TextFileContent content = new TextFileContent("/project/empty.txt", "");
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/search/config/SearchLimitsTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/config/SearchLimitsTest.java
new file mode 100644
index 0000000000..69a2c8f2fb
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/search/config/SearchLimitsTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.config;
+
+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 SearchLimitsTest {
+
+ @Test
+ void defaultsMatchProductSafetyLimits() {
+ SearchLimits limits = SearchLimits.defaults();
+ assertEquals(3, limits.getMinContentQueryLength());
+ assertEquals(500, limits.getMaxResults());
+ assertEquals(20, limits.getMaxMatchesPerFile());
+ assertEquals(1L * 1024 * 1024, limits.getMaxTextFileSizeBytes());
+ assertTrue(limits.isIncludeProjectTextFiles());
+ assertTrue(limits.isSearchAsYouType());
+ assertEquals(300, limits.getDebounceMs());
+ }
+
+ @Test
+ void contentSearchRequiresMinLength() {
+ SearchLimits limits = SearchLimits.defaults();
+ assertFalse(limits.allowsContentSearch("a"));
+ assertFalse(limits.allowsContentSearch("ad"));
+ assertTrue(limits.allowsContentSearch("add"));
+ assertTrue(limits.allowsContentSearch("address"));
+ }
+
+ @Test
+ void customConfigIsParsed() {
+ SearchConfig config = new SearchConfig();
+ config.setMinContentQueryLength("5");
+ config.setMaxResults("100");
+ config.setMaxMatchesPerFile("7");
+ config.setMaxTextFileSizeMb("2");
+ config.setIncludeProjectTextFiles(false);
+ config.setSearchAsYouType(false);
+ config.setDebounceMs("400");
+
+ SearchLimits limits = SearchLimits.fromConfig(config);
+ assertEquals(5, limits.getMinContentQueryLength());
+ assertEquals(100, limits.getMaxResults());
+ assertEquals(7, limits.getMaxMatchesPerFile());
+ assertEquals(2L * 1024 * 1024, limits.getMaxTextFileSizeBytes());
+ assertFalse(limits.isIncludeProjectTextFiles());
+ assertFalse(limits.isSearchAsYouType());
+ assertEquals(400, limits.getDebounceMs());
+ assertFalse(limits.allowsContentSearch("addr"));
+ assertTrue(limits.allowsContentSearch("addre"));
+ }
+}