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;
+ }
+}