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 d7cc0f4e7b Issue #7903 : Add find and replace to the explorer content 
editor (#7947)
d7cc0f4e7b is described below

commit d7cc0f4e7b1c7589317e9b0f3577be0fcc1817e0
Author: Matt Casters <[email protected]>
AuthorDate: Fri Aug 14 12:19:24 2026 +0200

    Issue #7903 : Add find and replace to the explorer content editor (#7947)
    
    Reuse FindReplaceDialog for BaseTextExplorerFileType tabs (open as text,
    YAML, SQL, XML, logs, markdown) via the shared content-editor toolbar
    and desktop popup menu.
---
 .../hop/ui/hopgui/ContentEditorFacadeImpl.java     | 211 ++++++++++++++++++++-
 .../org/apache/hop/ui/hopgui/monaco-editor.js      | 107 ++++++++++-
 .../apache/hop/ui/hopgui/ContentEditorWidget.java  | 134 +++++++++++++
 .../hop/ui/core/dialog/FindReplaceDialog.java      | 106 +++--------
 .../hop/ui/core/widget/FindReplaceOperations.java  | 126 ++++++++++++
 .../hop/ui/core/widget/IFindReplaceTarget.java     |  98 ++++++++++
 .../apache/hop/ui/core/widget/TextComposite.java   |   2 +-
 .../apache/hop/ui/core/widget/TextFindSupport.java |   2 +-
 .../core/widget/editor/IContentEditorWidget.java   |  15 +-
 .../apache/hop/ui/hopgui/ContentEditorActions.java |  40 ++++
 .../ui/hopgui/messages/messages_en_US.properties   |   2 +
 .../ui/core/widget/FindReplaceOperationsTest.java  | 158 +++++++++++++++
 .../hop/ui/hopgui/ContentEditorActionsTest.java    |  52 +++++
 13 files changed, 966 insertions(+), 87 deletions(-)

diff --git 
a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
index 6d02102e1f..c8ee26bc05 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
@@ -81,6 +81,8 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       remoteObject.setHandler(widget.getOperationHandler());
       remoteObject.listen("contentChanged", true);
       remoteObject.listen("focusChanged", true);
+      remoteObject.listen("selectionChanged", true);
+      remoteObject.listen("findRequested", true);
       host.addListener(
           SWT.Dispose,
           event -> {
@@ -108,6 +110,24 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     RapContentEditorWidget widget = new RapContentEditorWidget(root, text, 
languageId);
     Control toolbar = addToolbar(root, widget);
     
text.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
+    text.addListener(
+        SWT.KeyDown,
+        event -> {
+          if ((event.stateMask & SWT.MOD1) == 0 || (event.stateMask & 
SWT.MOD2) != 0) {
+            return;
+          }
+          if (event.keyCode == 'f') {
+            ContentEditorActions.find(widget);
+            event.doit = false;
+          } else if (event.keyCode == 'h') {
+            if (widget.isEditable()) {
+              ContentEditorActions.findAndReplace(widget);
+            } else {
+              ContentEditorActions.find(widget);
+            }
+            event.doit = false;
+          }
+        });
     return widget;
   }
 
@@ -136,16 +156,30 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     toolbarWidgets.registerGuiPluginObject(widget);
     toolbarWidgets.createToolbarWidgets(
         toolbarContainer, IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID);
+    if (widget instanceof RapToolbarAware toolbarAware) {
+      toolbarAware.setToolbarWidgets(toolbarWidgets);
+      toolbarAware.updateToolbar();
+    }
     toolbar.pack();
     return toolbar;
   }
 
-  private static class RapMonacoEditorWidget implements IContentEditorWidget {
+  private interface RapToolbarAware {
+    void setToolbarWidgets(GuiToolbarWidgets toolbarWidgets);
+
+    void updateToolbar();
+  }
+
+  private static class RapMonacoEditorWidget implements IContentEditorWidget, 
RapToolbarAware {
 
     private final Composite root;
     private final RemoteObject remoteObject;
     private final Display display;
     private volatile String cachedContent = "";
+    private volatile int selectionStart;
+    private volatile int selectionEnd;
+    private volatile boolean readOnly;
+    private GuiToolbarWidgets toolbarWidgets;
     private final java.util.List<ModifyListener> modifyListeners = new 
CopyOnWriteArrayList<>();
     private boolean suppressModify;
     private volatile String languageId;
@@ -162,7 +196,36 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
             @Override
             public void handleNotify(String event, JsonObject properties) {
               if ("focusChanged".equals(event) && properties.get("focused") != 
null) {
-                setFocus(properties.get("focused").asBoolean());
+                setEditorFocused(properties.get("focused").asBoolean());
+                return;
+              }
+              if ("selectionChanged".equals(event)) {
+                if (properties.get("start") != null) {
+                  selectionStart = properties.get("start").asInt();
+                }
+                if (properties.get("end") != null) {
+                  selectionEnd = properties.get("end").asInt();
+                }
+                return;
+              }
+              if ("findRequested".equals(event)) {
+                boolean replace =
+                    properties.get("replace") != null && 
properties.get("replace").asBoolean();
+                Display current = host.getDisplay();
+                if (current == null || host.isDisposed()) {
+                  return;
+                }
+                current.asyncExec(
+                    () -> {
+                      if (host.isDisposed()) {
+                        return;
+                      }
+                      if (replace && isEditable()) {
+                        
ContentEditorActions.findAndReplace(RapMonacoEditorWidget.this);
+                      } else {
+                        ContentEditorActions.find(RapMonacoEditorWidget.this);
+                      }
+                    });
                 return;
               }
               if (!"contentChanged".equals(event) || properties.get("content") 
== null) {
@@ -197,7 +260,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
           };
     }
 
-    private void setFocus(boolean focused) {
+    private void setEditorFocused(boolean focused) {
       if (display == null || display.isDisposed()) {
         return;
       }
@@ -210,7 +273,12 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     }
 
     void clearFocus() {
-      setFocus(false);
+      setEditorFocused(false);
+    }
+
+    @Override
+    public void setToolbarWidgets(GuiToolbarWidgets toolbarWidgets) {
+      this.toolbarWidgets = toolbarWidgets;
     }
 
     AbstractOperationHandler getOperationHandler() {
@@ -231,6 +299,8 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     public void setText(String content) {
       String s = content != null ? content : "";
       cachedContent = s;
+      selectionStart = 0;
+      selectionEnd = 0;
       remoteObject.set("content", s);
     }
 
@@ -257,7 +327,9 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
 
     @Override
     public void setReadOnly(boolean readOnly) {
+      this.readOnly = readOnly;
       remoteObject.set("readOnly", readOnly);
+      updateToolbar();
     }
 
     @Override
@@ -308,15 +380,88 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     public void redo() {
       // no-op for Monaco until redo remote ops exist
     }
+
+    @Override
+    public String getSelectionText() {
+      int start = clampedOffset(Math.min(selectionStart, selectionEnd));
+      int end = clampedOffset(Math.max(selectionStart, selectionEnd));
+      if (end <= start) {
+        return "";
+      }
+      return cachedContent.substring(start, end);
+    }
+
+    @Override
+    public int getSelectionCount() {
+      return Math.abs(selectionEnd - selectionStart);
+    }
+
+    @Override
+    public void setSelection(int start, int end) {
+      selectionStart = Math.max(0, start);
+      selectionEnd = Math.max(selectionStart, end);
+      JsonObject obj = new JsonObject();
+      obj.add("start", selectionStart);
+      obj.add("end", selectionEnd);
+      remoteObject.call("setSelection", obj);
+    }
+
+    @Override
+    public int getCaretPosition() {
+      return Math.max(selectionStart, selectionEnd);
+    }
+
+    @Override
+    public void setCaretPosition(int position) {
+      setSelection(position, position);
+    }
+
+    @Override
+    public void insert(String text) {
+      String insertion = text != null ? text : "";
+      int start = clampedOffset(Math.min(selectionStart, selectionEnd));
+      int end = clampedOffset(Math.max(selectionStart, selectionEnd));
+      cachedContent = cachedContent.substring(0, start) + insertion + 
cachedContent.substring(end);
+      int caret = start + insertion.length();
+      selectionStart = caret;
+      selectionEnd = caret;
+      JsonObject obj = new JsonObject();
+      obj.add("text", insertion);
+      obj.add("start", start);
+      obj.add("end", end);
+      remoteObject.call("insert", obj);
+    }
+
+    @Override
+    public boolean isEditable() {
+      return !readOnly;
+    }
+
+    @Override
+    public void updateToolbar() {
+      if (toolbarWidgets == null) {
+        return;
+      }
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND, 
true);
+      
toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, 
isEditable());
+    }
+
+    private int clampedOffset(int offset) {
+      if (offset < 0) {
+        return 0;
+      }
+      return Math.min(offset, cachedContent.length());
+    }
   }
 
-  private static class RapContentEditorWidget implements IContentEditorWidget {
+  private static class RapContentEditorWidget implements IContentEditorWidget, 
RapToolbarAware {
 
     private final Composite root;
     private final Text text;
     private final java.util.List<ModifyListener> modifyListeners = new 
CopyOnWriteArrayList<>();
     private boolean suppressModify;
     private volatile String languageId;
+    private GuiToolbarWidgets toolbarWidgets;
 
     RapContentEditorWidget(Composite root, Text text, String languageId) {
       this.root = root;
@@ -373,6 +518,12 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     @Override
     public void setReadOnly(boolean readOnly) {
       text.setEditable(!readOnly);
+      updateToolbar();
+    }
+
+    @Override
+    public void setToolbarWidgets(GuiToolbarWidgets toolbarWidgets) {
+      this.toolbarWidgets = toolbarWidgets;
     }
 
     @Override
@@ -419,5 +570,55 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     public void redo() {
       // SWT Text has no standard redo API
     }
+
+    @Override
+    public String getSelectionText() {
+      String selected = text.getSelectionText();
+      return selected != null ? selected : "";
+    }
+
+    @Override
+    public int getSelectionCount() {
+      return text.getSelectionCount();
+    }
+
+    @Override
+    public void setSelection(int start, int end) {
+      text.setSelection(start, end);
+    }
+
+    @Override
+    public int getCaretPosition() {
+      return text.getCaretPosition();
+    }
+
+    @Override
+    public void setCaretPosition(int position) {
+      text.setSelection(position);
+    }
+
+    @Override
+    public void insert(String content) {
+      text.insert(content != null ? content : "");
+    }
+
+    @Override
+    public boolean isEditable() {
+      return text.getEditable();
+    }
+
+    @Override
+    public boolean setFocus() {
+      return !text.isDisposed() && text.setFocus();
+    }
+
+    @Override
+    public void updateToolbar() {
+      if (toolbarWidgets == null) {
+        return;
+      }
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND, 
true);
+      
toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, 
isEditable());
+    }
   }
 }
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
index 3393a71772..4ea5eb7ba3 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
@@ -88,7 +88,8 @@
       }
     },
     properties: [ "content", "language", "readOnly", "theme" ],
-    events: [ "contentChanged", "focusChanged" ]
+    events: [ "contentChanged", "focusChanged", "selectionChanged", 
"findRequested" ],
+    methods: [ "setSelection", "insert" ]
   });
 
   rwt.define("hop");
@@ -150,6 +151,60 @@
       }
     },
 
+    setSelection: function(properties) {
+      if (!this._editor || !this._editor.getModel()) {
+        return;
+      }
+      var model = this._editor.getModel();
+      var start = properties && properties.start != null ? properties.start : 
0;
+      var end = properties && properties.end != null ? properties.end : start;
+      start = Math.max(0, Math.min(start, model.getValueLength()));
+      end = Math.max(start, Math.min(end, model.getValueLength()));
+      var startPos = model.getPositionAt(start);
+      var endPos = model.getPositionAt(end);
+      var range = window.monaco.Range.fromPositions(startPos, endPos);
+      this._editor.setSelection(range);
+      this._editor.revealRangeInCenterIfOutsideViewport(range);
+    },
+
+    insert: function(properties) {
+      if (!this._editor || !this._editor.getModel()) {
+        return;
+      }
+      var model = this._editor.getModel();
+      var text = properties && properties.text != null ? properties.text : "";
+      var start;
+      var end;
+      if (properties && properties.start != null) {
+        start = Math.max(0, Math.min(properties.start, 
model.getValueLength()));
+        end = properties.end != null
+          ? Math.max(start, Math.min(properties.end, model.getValueLength()))
+          : start;
+      } else {
+        var selection = this._editor.getSelection();
+        if (!selection) {
+          return;
+        }
+        start = model.getOffsetAt({
+          lineNumber: selection.startLineNumber,
+          column: selection.startColumn
+        });
+        end = model.getOffsetAt({
+          lineNumber: selection.endLineNumber,
+          column: selection.endColumn
+        });
+      }
+      var range = window.monaco.Range.fromPositions(
+        model.getPositionAt(start),
+        model.getPositionAt(end)
+      );
+      this._editor.executeEdits("hop-find-replace", [{
+        range: range,
+        text: text,
+        forceMoveMarkers: true
+      }]);
+    },
+
     _notifyServer: function() {
       if (this._destroyed || !this._editor || this._readOnly) return;
       var value = this._editor.getValue();
@@ -174,6 +229,41 @@
       }
     },
 
+    _notifySelection: function() {
+      if (this._destroyed || !this._editor || !this._editor.getModel()) return;
+      try {
+        var model = this._editor.getModel();
+        var sel = this._editor.getSelection();
+        if (!sel) return;
+        var start = model.getOffsetAt({
+          lineNumber: sel.startLineNumber,
+          column: sel.startColumn
+        });
+        var end = model.getOffsetAt({
+          lineNumber: sel.endLineNumber,
+          column: sel.endColumn
+        });
+        var remote = rap.getRemoteObject(this);
+        if (remote) {
+          remote.notify("selectionChanged", { start: start, end: end });
+        }
+      } catch (e) {
+        console.warn("MonacoEditor: failed to notify selection", e);
+      }
+    },
+
+    _notifyFind: function(replace) {
+      if (this._destroyed) return;
+      try {
+        var remote = rap.getRemoteObject(this);
+        if (remote) {
+          remote.notify("findRequested", { replace: !!replace });
+        }
+      } catch (e) {
+        console.warn("MonacoEditor: failed to notify find", e);
+      }
+    },
+
     _scheduleNotify: function() {
       var self = this;
       if (self._debounceId) clearTimeout(self._debounceId);
@@ -241,6 +331,21 @@
           self._scheduleNotify();
         });
 
+        self._editor.onDidChangeCursorSelection(function() {
+          self._notifySelection();
+        });
+
+        if (window.monaco && window.monaco.KeyMod && window.monaco.KeyCode) {
+          self._editor.addCommand(
+            window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyF,
+            function() { self._notifyFind(false); }
+          );
+          self._editor.addCommand(
+            window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyH,
+            function() { self._notifyFind(true); }
+          );
+        }
+
         self._editor.onDidFocusEditorWidget(function() {
           self._notifyFocus(true);
         });
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
index 4e22e4cb73..723a8c7392 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
@@ -31,6 +31,7 @@ import 
org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.FindReplaceDialog;
 import org.apache.hop.ui.core.gui.GuiMenuWidgets;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
@@ -99,6 +100,9 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
   public static final String ID_CONTEXT_MENU_COPY = 
"ContentEditor-ContextMenu-30000-copy";
   public static final String ID_CONTEXT_MENU_PASTE = 
"ContentEditor-ContextMenu-30010-paste";
   public static final String ID_CONTEXT_MENU_CUT = 
"ContentEditor-ContextMenu-30020-cut";
+  public static final String ID_CONTEXT_MENU_FIND = 
"ContentEditor-ContextMenu-40000-find";
+  public static final String ID_CONTEXT_MENU_FIND_REPLACE =
+      "ContentEditor-ContextMenu-40010-find-replace";
 
   private static final char[] OPEN_BRACKETS = {'(', '[', '{'};
   private static final char[] CLOSE_BRACKETS = {')', ']', '}'};
@@ -290,6 +294,9 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
       toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_CUT, 
canCut);
       toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_COPY, 
canCopy);
       toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_PASTE, 
canPaste);
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_FIND, 
true);
+      toolbarWidgets.enableToolbarItem(
+          ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, 
sourceViewer.isEditable());
     }
 
     // Update the HopGui main menu items...
@@ -375,6 +382,91 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
   @Override
   public void setReadOnly(boolean readOnly) {
     sourceViewer.setEditable(!readOnly);
+    updateGui();
+  }
+
+  @Override
+  public String getSelectionText() {
+    org.eclipse.swt.graphics.Point range = sourceViewer.getSelectedRange();
+    if (range == null || range.y <= 0) {
+      return "";
+    }
+    IDocument doc = sourceViewer.getDocument();
+    if (doc == null) {
+      return "";
+    }
+    try {
+      return doc.get(range.x, range.y);
+    } catch (org.eclipse.jface.text.BadLocationException e) {
+      return "";
+    }
+  }
+
+  @Override
+  public int getSelectionCount() {
+    org.eclipse.swt.graphics.Point range = sourceViewer.getSelectedRange();
+    return range != null && range.y > 0 ? range.y : 0;
+  }
+
+  @Override
+  public void setSelection(int start, int end) {
+    int safeStart = Math.max(0, start);
+    int length = Math.max(0, end - safeStart);
+    sourceViewer.setSelectedRange(safeStart, length);
+    sourceViewer.revealRange(safeStart, length);
+  }
+
+  @Override
+  public int getCaretPosition() {
+    StyledText textWidget = sourceViewer.getTextWidget();
+    if (textWidget == null || textWidget.isDisposed()) {
+      org.eclipse.swt.graphics.Point range = sourceViewer.getSelectedRange();
+      return range != null ? range.x + range.y : 0;
+    }
+    return textWidget.getCaretOffset();
+  }
+
+  @Override
+  public void setCaretPosition(int position) {
+    int safe = Math.max(0, position);
+    IDocument doc = sourceViewer.getDocument();
+    if (doc != null) {
+      safe = Math.min(safe, doc.getLength());
+    }
+    sourceViewer.setSelectedRange(safe, 0);
+    sourceViewer.revealRange(safe, 0);
+  }
+
+  @Override
+  public void insert(String text) {
+    String insertion = text != null ? text : "";
+    org.eclipse.swt.graphics.Point range = sourceViewer.getSelectedRange();
+    IDocument doc = sourceViewer.getDocument();
+    if (doc == null || range == null) {
+      return;
+    }
+    try {
+      doc.replace(range.x, range.y, insertion);
+      sourceViewer.setSelectedRange(range.x + insertion.length(), 0);
+    } catch (org.eclipse.jface.text.BadLocationException e) {
+      // ignore invalid range
+    }
+  }
+
+  @Override
+  public boolean isEditable() {
+    return sourceViewer != null && sourceViewer.isEditable();
+  }
+
+  @Override
+  public boolean setFocus() {
+    StyledText textWidget = sourceViewer.getTextWidget();
+    return textWidget != null && !textWidget.isDisposed() && 
textWidget.setFocus();
+  }
+
+  @Override
+  public void updateToolbar() {
+    updateGui();
   }
 
   @Override
@@ -487,6 +579,27 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
     sourceViewer.doOperation(SourceViewer.PASTE);
   }
 
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_FIND,
+      label = "i18n::ContentEditorWidget.Menu.Find",
+      image = "ui/images/search.svg",
+      separator = true)
+  public void find() {
+    FindReplaceDialog.open(control.getShell(), this, false);
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_FIND_REPLACE,
+      label = "i18n::ContentEditorWidget.Menu.FindReplace",
+      image = "ui/images/edit.svg")
+  public void findAndReplace() {
+    FindReplaceDialog.open(control.getShell(), this, true);
+  }
+
   private static void applyFontFromHop(SourceViewer sourceViewer) {
     try {
       org.eclipse.swt.graphics.Font swtFont = 
GuiResource.getInstance().getFontFixed();
@@ -655,6 +768,27 @@ public class ContentEditorWidget implements 
IContentEditorWidget {
               ID_CONTEXT_MENU_COPY, 
sourceViewer.canDoOperation(ITextOperationTarget.COPY));
           contextMenuWidgets.enableMenuItem(
               ID_CONTEXT_MENU_PASTE, 
sourceViewer.canDoOperation(ITextOperationTarget.PASTE));
+          contextMenuWidgets.enableMenuItem(ID_CONTEXT_MENU_FIND, true);
+          contextMenuWidgets.enableMenuItem(
+              ID_CONTEXT_MENU_FIND_REPLACE, sourceViewer.isEditable());
+        });
+    styledText.addListener(
+        SWT.KeyDown,
+        event -> {
+          if ((event.stateMask & SWT.MOD1) == 0 || (event.stateMask & 
SWT.MOD2) != 0) {
+            return;
+          }
+          if (event.keyCode == 'f') {
+            find();
+            event.doit = false;
+          } else if (event.keyCode == 'h') {
+            if (sourceViewer.isEditable()) {
+              findAndReplace();
+            } else {
+              find();
+            }
+            event.doit = false;
+          }
         });
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/FindReplaceDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/FindReplaceDialog.java
index 43b641ffab..2b7f542931 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/FindReplaceDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/FindReplaceDialog.java
@@ -22,8 +22,8 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.WindowProperty;
-import org.apache.hop.ui.core.widget.TextComposite;
-import org.apache.hop.ui.core.widget.TextFindSupport;
+import org.apache.hop.ui.core.widget.FindReplaceOperations;
+import org.apache.hop.ui.core.widget.IFindReplaceTarget;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.layout.FormAttachment;
@@ -36,7 +36,8 @@ import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.Text;
 
 /**
- * Find / find-and-replace dialog for multi-line {@link TextComposite} editors 
(SQL, scripts, logs).
+ * Find / find-and-replace dialog for multi-line editors ({@link
+ * org.apache.hop.ui.core.widget.TextComposite} and the explorer content 
editor).
  */
 public class FindReplaceDialog extends Dialog {
   private static final Class<?> PKG = FindReplaceDialog.class;
@@ -45,7 +46,7 @@ public class FindReplaceDialog extends Dialog {
   private static String lastReplace = "";
   private static boolean lastCaseSensitive = false;
 
-  private final TextComposite textComposite;
+  private final IFindReplaceTarget target;
   private final boolean replaceMode;
   private final PropsUi props;
 
@@ -55,25 +56,25 @@ public class FindReplaceDialog extends Dialog {
   private Button wCaseSensitive;
   private Label wlStatus;
 
-  private FindReplaceDialog(Shell parent, TextComposite textComposite, boolean 
replaceMode) {
+  private FindReplaceDialog(Shell parent, IFindReplaceTarget target, boolean 
replaceMode) {
     super(parent, SWT.NONE);
-    this.textComposite = textComposite;
+    this.target = target;
     this.replaceMode = replaceMode;
     this.props = PropsUi.getInstance();
   }
 
   /**
-   * Open a find or find-and-replace dialog for the given text composite.
+   * Open a find or find-and-replace dialog for the given editor.
    *
    * @param parent parent shell
-   * @param textComposite target editor
+   * @param target target editor
    * @param replaceMode when true, show replace controls
    */
-  public static void open(Shell parent, TextComposite textComposite, boolean 
replaceMode) {
-    if (parent == null || textComposite == null || textComposite.isDisposed()) 
{
+  public static void open(Shell parent, IFindReplaceTarget target, boolean 
replaceMode) {
+    if (parent == null || target == null || target.isDisposed()) {
       return;
     }
-    new FindReplaceDialog(parent, textComposite, replaceMode).openDialog();
+    new FindReplaceDialog(parent, target, replaceMode).openDialog();
   }
 
   private void openDialog() {
@@ -169,12 +170,12 @@ public class FindReplaceDialog extends Dialog {
       wReplaceOne = new Button(shell, SWT.PUSH);
       wReplaceOne.setText(BaseMessages.getString(PKG, 
"FindReplaceDialog.Replace.Button"));
       wReplaceOne.addListener(SWT.Selection, e -> replaceOne());
-      wReplaceOne.setEnabled(textComposite.isEditable());
+      wReplaceOne.setEnabled(target.isEditable());
 
       wReplaceAll = new Button(shell, SWT.PUSH);
       wReplaceAll.setText(BaseMessages.getString(PKG, 
"FindReplaceDialog.ReplaceAll.Button"));
       wReplaceAll.addListener(SWT.Selection, e -> replaceAll());
-      wReplaceAll.setEnabled(textComposite.isEditable());
+      wReplaceAll.setEnabled(target.isEditable());
     }
 
     Button wClose = new Button(shell, SWT.PUSH);
@@ -190,7 +191,7 @@ public class FindReplaceDialog extends Dialog {
     BaseTransformDialog.positionBottomButtons(shell, buttons, margin, 
wlStatus);
 
     // Defaults
-    String selection = textComposite.getSelectionText();
+    String selection = target.getSelectionText();
     if (StringUtils.isNotEmpty(selection) && !selection.contains("\n")) {
       wFind.setText(selection);
     } else if (StringUtils.isNotEmpty(lastFind)) {
@@ -209,7 +210,7 @@ public class FindReplaceDialog extends Dialog {
   }
 
   private void findNext(boolean forward) {
-    if (textComposite.isDisposed()) {
+    if (target.isDisposed()) {
       close();
       return;
     }
@@ -219,44 +220,7 @@ public class FindReplaceDialog extends Dialog {
       return;
     }
     rememberOptions();
-
-    String content = textComposite.getText();
-    boolean caseSensitive = wCaseSensitive.getSelection();
-    int caret = textComposite.getCaretPosition();
-    int selectionLen = textComposite.getSelectionCount();
-
-    int found;
-    if (forward) {
-      // Start after current selection when it matches (or after caret)
-      int from = caret;
-      if (selectionLen > 0) {
-        // Caret is typically at the end of the selection; start after it
-        from = Math.max(caret, caret); // caret already end in most cases
-        // If selection equals query, step past it
-        String selected = textComposite.getSelectionText();
-        if (matches(selected, query, caseSensitive)) {
-          from = caret; // end of selection
-        }
-      }
-      found = TextFindSupport.findNext(content, query, from, caseSensitive);
-      if (found < 0 && from > 0) {
-        // wrap
-        found = TextFindSupport.findNext(content, query, 0, caseSensitive);
-      }
-    } else {
-      int from = caret - selectionLen - 1;
-      if (from < 0) {
-        from = content.length();
-      }
-      found = TextFindSupport.findPrevious(content, query, from, 
caseSensitive);
-      if (found < 0) {
-        found = TextFindSupport.findPrevious(content, query, content.length(), 
caseSensitive);
-      }
-    }
-
-    if (found >= 0) {
-      textComposite.setSelection(found, found + query.length());
-      textComposite.setFocus();
+    if (FindReplaceOperations.find(target, query, 
wCaseSensitive.getSelection(), forward)) {
       setStatus("");
     } else {
       setStatus(BaseMessages.getString(PKG, 
"FindReplaceDialog.Status.NotFound"));
@@ -264,7 +228,7 @@ public class FindReplaceDialog extends Dialog {
   }
 
   private void replaceOne() {
-    if (textComposite.isDisposed() || !textComposite.isEditable()) {
+    if (target.isDisposed() || !target.isEditable()) {
       return;
     }
     String query = wFind.getText();
@@ -273,17 +237,17 @@ public class FindReplaceDialog extends Dialog {
       return;
     }
     rememberOptions();
-    String selected = textComposite.getSelectionText();
-    if (matches(selected, query, wCaseSensitive.getSelection())) {
-      String replacement = wReplace != null ? wReplace.getText() : "";
-      textComposite.insert(replacement);
-      textComposite.updateToolbar();
+    String replacement = wReplace != null ? wReplace.getText() : "";
+    if (FindReplaceOperations.replaceOne(
+        target, query, replacement, wCaseSensitive.getSelection())) {
+      setStatus("");
+    } else {
+      setStatus(BaseMessages.getString(PKG, 
"FindReplaceDialog.Status.NotFound"));
     }
-    findNext(true);
   }
 
   private void replaceAll() {
-    if (textComposite.isDisposed() || !textComposite.isEditable()) {
+    if (target.isDisposed() || !target.isEditable()) {
       return;
     }
     String query = wFind.getText();
@@ -293,25 +257,11 @@ public class FindReplaceDialog extends Dialog {
     }
     rememberOptions();
     String replacement = wReplace != null ? wReplace.getText() : "";
-    TextFindSupport.ReplaceAllResult result =
-        TextFindSupport.replaceAll(
-            textComposite.getText(), query, replacement, 
wCaseSensitive.getSelection());
-    if (result.count() > 0) {
-      int caret = textComposite.getCaretPosition();
-      textComposite.setText(result.text());
-      textComposite.setCaretPosition(Math.min(caret, result.text().length()));
-      textComposite.updateToolbar();
-    }
+    int count =
+        FindReplaceOperations.replaceAll(target, query, replacement, 
wCaseSensitive.getSelection());
     setStatus(
         BaseMessages.getString(
-            PKG, "FindReplaceDialog.Status.ReplaceAllCount", 
Integer.toString(result.count())));
-  }
-
-  private static boolean matches(String value, String query, boolean 
caseSensitive) {
-    if (value == null || query == null) {
-      return false;
-    }
-    return caseSensitive ? value.equals(query) : value.equalsIgnoreCase(query);
+            PKG, "FindReplaceDialog.Status.ReplaceAllCount", 
Integer.toString(count)));
   }
 
   private void rememberOptions() {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/FindReplaceOperations.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/FindReplaceOperations.java
new file mode 100644
index 0000000000..91c21fb17f
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/FindReplaceOperations.java
@@ -0,0 +1,126 @@
+/*
+ * 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.core.widget;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Headless find / replace steps used by {@link 
org.apache.hop.ui.core.dialog.FindReplaceDialog}.
+ */
+public final class FindReplaceOperations {
+
+  private FindReplaceOperations() {}
+
+  /**
+   * Find the next or previous match, select it, and focus the editor.
+   *
+   * @return {@code true} when a match was selected
+   */
+  public static boolean find(
+      IFindReplaceTarget target, String query, boolean caseSensitive, boolean 
forward) {
+    if (target == null || target.isDisposed() || StringUtils.isEmpty(query)) {
+      return false;
+    }
+    String content = target.getText();
+    if (content == null) {
+      content = "";
+    }
+    int caret = target.getCaretPosition();
+    int selectionLen = target.getSelectionCount();
+
+    int found;
+    if (forward) {
+      int from = caret;
+      String selected = target.getSelectionText();
+      if (selectionLen > 0 && matches(selected, query, caseSensitive)) {
+        from = caret;
+      }
+      found = TextFindSupport.findNext(content, query, from, caseSensitive);
+      if (found < 0 && from > 0) {
+        found = TextFindSupport.findNext(content, query, 0, caseSensitive);
+      }
+    } else {
+      int from = caret - selectionLen - 1;
+      if (from < 0) {
+        from = content.length();
+      }
+      found = TextFindSupport.findPrevious(content, query, from, 
caseSensitive);
+      if (found < 0) {
+        found = TextFindSupport.findPrevious(content, query, content.length(), 
caseSensitive);
+      }
+    }
+
+    if (found < 0) {
+      return false;
+    }
+    target.setSelection(found, found + query.length());
+    target.setFocus();
+    return true;
+  }
+
+  /**
+   * Replace the current selection when it matches {@code query}, then find 
the next match.
+   *
+   * @return {@code true} when a following match was selected
+   */
+  public static boolean replaceOne(
+      IFindReplaceTarget target, String query, String replacement, boolean 
caseSensitive) {
+    if (target == null || target.isDisposed() || !target.isEditable()) {
+      return false;
+    }
+    if (StringUtils.isEmpty(query)) {
+      return false;
+    }
+    if (matches(target.getSelectionText(), query, caseSensitive)) {
+      target.insert(replacement != null ? replacement : "");
+      target.updateToolbar();
+    }
+    return find(target, query, caseSensitive, true);
+  }
+
+  /**
+   * Replace every occurrence of {@code query}.
+   *
+   * @return number of replacements performed
+   */
+  public static int replaceAll(
+      IFindReplaceTarget target, String query, String replacement, boolean 
caseSensitive) {
+    if (target == null || target.isDisposed() || !target.isEditable()) {
+      return 0;
+    }
+    if (StringUtils.isEmpty(query)) {
+      return 0;
+    }
+    TextFindSupport.ReplaceAllResult result =
+        TextFindSupport.replaceAll(target.getText(), query, replacement, 
caseSensitive);
+    if (result.count() > 0) {
+      int caret = target.getCaretPosition();
+      target.setText(result.text());
+      target.setCaretPosition(Math.min(caret, result.text().length()));
+      target.updateToolbar();
+    }
+    return result.count();
+  }
+
+  static boolean matches(String value, String query, boolean caseSensitive) {
+    if (value == null || query == null) {
+      return false;
+    }
+    return caseSensitive ? value.equals(query) : value.equalsIgnoreCase(query);
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java
new file mode 100644
index 0000000000..836a38eba4
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/IFindReplaceTarget.java
@@ -0,0 +1,98 @@
+/*
+ * 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.core.widget;
+
+/**
+ * Editor operations required by {@link 
org.apache.hop.ui.core.dialog.FindReplaceDialog}.
+ *
+ * <p>Implemented by multi-line {@link TextComposite} widgets and by {@code 
IContentEditorWidget}
+ * (explorer text files). Keep this interface free of desktop-only SWT types 
so Hop Web can
+ * implement it.
+ */
+public interface IFindReplaceTarget {
+
+  /**
+   * @return full editor text
+   */
+  String getText();
+
+  /**
+   * Replace the full editor text.
+   *
+   * @param text new content
+   */
+  void setText(String text);
+
+  /**
+   * @return selected text, or an empty string if there is no selection
+   */
+  String getSelectionText();
+
+  /**
+   * @return number of selected characters
+   */
+  int getSelectionCount();
+
+  /**
+   * Select the range {@code [start, end)}.
+   *
+   * @param start start offset (inclusive)
+   * @param end end offset (exclusive)
+   */
+  void setSelection(int start, int end);
+
+  /**
+   * @return caret offset from the start of the text
+   */
+  int getCaretPosition();
+
+  /**
+   * Move the caret (and collapse the selection).
+   *
+   * @param position caret offset
+   */
+  void setCaretPosition(int position);
+
+  /**
+   * Replace the current selection with {@code text}, or insert at the caret 
when nothing is
+   * selected.
+   *
+   * @param text replacement or insertion
+   */
+  void insert(String text);
+
+  /**
+   * @return {@code true} when the user can edit the text
+   */
+  boolean isEditable();
+
+  /**
+   * @return {@code true} when the underlying control has been disposed
+   */
+  boolean isDisposed();
+
+  /**
+   * Give focus to the editor.
+   *
+   * @return {@code true} if focus was assigned
+   */
+  boolean setFocus();
+
+  /** Refresh toolbar enablement after an edit. Default is a no-op. */
+  default void updateToolbar() {}
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
index acc34aae23..ed1efb98c6 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
@@ -71,7 +71,7 @@ import org.eclipse.swt.widgets.MenuItem;
  * }</pre>
  */
 @GuiPlugin
-public abstract class TextComposite extends Composite {
+public abstract class TextComposite extends Composite implements 
IFindReplaceTarget {
   private static final Class<?> PKG = StyledTextComp.class;
 
   public static final String ID_TOOLBAR = "TextComposite-Toolbar";
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/TextFindSupport.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TextFindSupport.java
index 09468a7844..609f0fd7be 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TextFindSupport.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextFindSupport.java
@@ -20,7 +20,7 @@ package org.apache.hop.ui.core.widget;
 import java.util.Locale;
 import org.apache.commons.lang3.StringUtils;
 
-/** Pure string helpers for find / replace in a {@link TextComposite}. */
+/** Pure string helpers for find / replace in an {@link IFindReplaceTarget}. */
 public final class TextFindSupport {
 
   private TextFindSupport() {}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
 
b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
index 0d571930cd..02113f6c9c 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.ui.core.widget.editor;
 
+import org.apache.hop.ui.core.widget.IFindReplaceTarget;
 import org.eclipse.swt.events.ModifyListener;
 import org.eclipse.swt.widgets.Control;
 import org.jspecify.annotations.Nullable;
@@ -28,7 +29,7 @@ import org.jspecify.annotations.Nullable;
  * <p>Allows setting/getting text, setting the language (for syntax 
highlighting), and listening for
  * modifications through a single API.
  */
-public interface IContentEditorWidget {
+public interface IContentEditorWidget extends IFindReplaceTarget {
 
   public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = 
"ContentEditor-Toolbar";
 
@@ -120,4 +121,16 @@ public interface IContentEditorWidget {
 
   /** Redo the last undone edit. No-op if not supported by the implementation. 
*/
   void redo();
+
+  @Override
+  default boolean isDisposed() {
+    Control control = getControl();
+    return control == null || control.isDisposed();
+  }
+
+  @Override
+  default boolean setFocus() {
+    Control control = getControl();
+    return control != null && !control.isDisposed() && control.setFocus();
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
index d115e59f9e..9ebf6e3fa2 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
@@ -20,7 +20,10 @@ package org.apache.hop.ui.hopgui;
 import org.apache.hop.core.gui.plugin.GuiPlugin;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementType;
+import org.apache.hop.ui.core.dialog.FindReplaceDialog;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
 
 /**
  * Shared content-editor toolbar actions for Hop GUI (desktop) and Hop Web.
@@ -43,6 +46,8 @@ public class ContentEditorActions {
   public static final String ID_TOOLBAR_COPY = 
"ContentEditor-Toolbar-30000-copy";
   public static final String ID_TOOLBAR_PASTE = 
"ContentEditor-Toolbar-30010-paste";
   public static final String ID_TOOLBAR_CUT = 
"ContentEditor-Toolbar-30020-cut";
+  public static final String ID_TOOLBAR_FIND = 
"ContentEditor-Toolbar-40000-find";
+  public static final String ID_TOOLBAR_FIND_REPLACE = 
"ContentEditor-Toolbar-40010-find-replace";
 
   private ContentEditorActions() {}
 
@@ -117,4 +122,39 @@ public class ContentEditorActions {
   public static void paste(IContentEditorWidget editor) {
     editor.paste();
   }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_FIND,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/search.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Find.Tooltip",
+      separator = true)
+  public static void find(IContentEditorWidget editor) {
+    Shell shell = shellOf(editor);
+    if (shell != null) {
+      FindReplaceDialog.open(shell, editor, false);
+    }
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_FIND_REPLACE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/edit.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Replace.Tooltip")
+  public static void findAndReplace(IContentEditorWidget editor) {
+    Shell shell = shellOf(editor);
+    if (shell != null) {
+      FindReplaceDialog.open(shell, editor, true);
+    }
+  }
+
+  private static Shell shellOf(IContentEditorWidget editor) {
+    if (editor == null || editor.isDisposed()) {
+      return null;
+    }
+    Control control = editor.getControl();
+    return control != null && !control.isDisposed() ? control.getShell() : 
null;
+  }
 }
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 528c4b8644..0aac9a2983 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -352,6 +352,8 @@ HopGui.LayoutCheck.Dialog.StreamSchemaPluginNotFound=Stream 
Schema Merge plugin
 HopGui.LayoutCheck.Dialog.ReplaceDummyError=Error replacing Dummy transform 
with Stream Schema Merge
 ContentEditorWidget.Menu.Copy=Copy
 ContentEditorWidget.Menu.Cut=Cut
+ContentEditorWidget.Menu.Find=Find\tCtrl+F
+ContentEditorWidget.Menu.FindReplace=Find/Replace\tCtrl+H
 ContentEditorWidget.Menu.Paste=Paste
 ContentEditorWidget.Menu.Redo=Redo
 ContentEditorWidget.Menu.SelectAll=Select All
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/widget/FindReplaceOperationsTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/widget/FindReplaceOperationsTest.java
new file mode 100644
index 0000000000..f2ddfe4c76
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/core/widget/FindReplaceOperationsTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.core.widget;
+
+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 FindReplaceOperationsTest {
+
+  @Test
+  void findNextSelectsMatchAndWraps() {
+    FakeTarget target = new FakeTarget("Select From from dual");
+
+    assertTrue(FindReplaceOperations.find(target, "from", false, true));
+    assertEquals("From", target.getSelectionText());
+
+    assertTrue(FindReplaceOperations.find(target, "from", false, true));
+    assertEquals("from", target.getSelectionText());
+
+    assertTrue(FindReplaceOperations.find(target, "from", false, true));
+    assertEquals("From", target.getSelectionText());
+  }
+
+  @Test
+  void findPreviousSelectsEarlierMatch() {
+    FakeTarget target = new FakeTarget("aaa bbb aaa");
+    target.setCaretPosition(target.getText().length());
+
+    assertTrue(FindReplaceOperations.find(target, "aaa", true, false));
+    assertEquals(8, target.selStart);
+    assertEquals("aaa", target.getSelectionText());
+  }
+
+  @Test
+  void findReturnsFalseWhenMissing() {
+    FakeTarget target = new FakeTarget("hello");
+    assertFalse(FindReplaceOperations.find(target, "missing", false, true));
+    assertFalse(FindReplaceOperations.find(target, "", false, true));
+  }
+
+  @Test
+  void replaceOneReplacesSelectionThenFindsNext() {
+    FakeTarget target = new FakeTarget("foo bar foo");
+    assertTrue(FindReplaceOperations.find(target, "foo", true, true));
+    assertTrue(FindReplaceOperations.replaceOne(target, "foo", "baz", true));
+    assertEquals("baz bar foo", target.getText());
+    assertEquals("foo", target.getSelectionText());
+  }
+
+  @Test
+  void replaceAllReplacesEveryMatch() {
+    FakeTarget target = new FakeTarget("Hello hello HELLO");
+    assertEquals(3, FindReplaceOperations.replaceAll(target, "hello", "Hi", 
false));
+    assertEquals("Hi Hi Hi", target.getText());
+  }
+
+  @Test
+  void replaceDoesNothingWhenReadOnly() {
+    FakeTarget target = new FakeTarget("foo foo");
+    target.editable = false;
+    assertFalse(FindReplaceOperations.replaceOne(target, "foo", "bar", true));
+    assertEquals(0, FindReplaceOperations.replaceAll(target, "foo", "bar", 
true));
+    assertEquals("foo foo", target.getText());
+  }
+
+  private static final class FakeTarget implements IFindReplaceTarget {
+    private String text;
+    private int selStart;
+    private int selEnd;
+    private boolean editable = true;
+    private boolean disposed;
+
+    private FakeTarget(String text) {
+      this.text = text;
+    }
+
+    @Override
+    public String getText() {
+      return text;
+    }
+
+    @Override
+    public void setText(String text) {
+      this.text = text != null ? text : "";
+    }
+
+    @Override
+    public String getSelectionText() {
+      if (selEnd <= selStart || selStart >= text.length()) {
+        return "";
+      }
+      return text.substring(selStart, Math.min(selEnd, text.length()));
+    }
+
+    @Override
+    public int getSelectionCount() {
+      return Math.max(0, selEnd - selStart);
+    }
+
+    @Override
+    public void setSelection(int start, int end) {
+      selStart = Math.max(0, start);
+      selEnd = Math.max(selStart, end);
+    }
+
+    @Override
+    public int getCaretPosition() {
+      return selEnd;
+    }
+
+    @Override
+    public void setCaretPosition(int position) {
+      selStart = position;
+      selEnd = position;
+    }
+
+    @Override
+    public void insert(String replacement) {
+      String safe = replacement != null ? replacement : "";
+      text = text.substring(0, selStart) + safe + text.substring(selEnd);
+      selStart = selStart + safe.length();
+      selEnd = selStart;
+    }
+
+    @Override
+    public boolean isEditable() {
+      return editable;
+    }
+
+    @Override
+    public boolean isDisposed() {
+      return disposed;
+    }
+
+    @Override
+    public boolean setFocus() {
+      return !disposed;
+    }
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/ContentEditorActionsTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/ContentEditorActionsTest.java
new file mode 100644
index 0000000000..526aa8ed7f
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/ContentEditorActionsTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.junit.jupiter.api.Test;
+
+class ContentEditorActionsTest {
+
+  @Test
+  void findToolbarItemsAreRegisteredOnContentEditorToolbar() throws Exception {
+    GuiToolbarElement find = toolbarElement("find");
+    GuiToolbarElement replace = toolbarElement("findAndReplace");
+
+    assertEquals(IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID, 
find.root());
+    assertEquals(ContentEditorActions.ID_TOOLBAR_FIND, find.id());
+    assertTrue(find.separator());
+    assertEquals("ui/images/search.svg", find.image());
+
+    assertEquals(IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID, 
replace.root());
+    assertEquals(ContentEditorActions.ID_TOOLBAR_FIND_REPLACE, replace.id());
+    assertEquals("ui/images/edit.svg", replace.image());
+  }
+
+  private static GuiToolbarElement toolbarElement(String methodName) throws 
Exception {
+    Method method = ContentEditorActions.class.getMethod(methodName, 
IContentEditorWidget.class);
+    GuiToolbarElement element = method.getAnnotation(GuiToolbarElement.class);
+    assertNotNull(element, methodName);
+    return element;
+  }
+}

Reply via email to