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 6dbab9a740 issue #7549 : content editor toolbar/context menu (help for 
#7550) (#7576)
6dbab9a740 is described below

commit 6dbab9a74046d042e87b93e7535af119f43b4296
Author: Matt Casters <[email protected]>
AuthorDate: Mon Jul 20 12:43:37 2026 +0200

    issue #7549 : content editor toolbar/context menu (help for #7550) (#7576)
    
    * Add a context menu and missing capability to the text editor #7549
    
    * issue #7549 : fix content editor toolbar for RAP and Markdown preview
    
    Unblock PR #7550 by addressing the three gaps Nicolas hit:
    
    - RAP implements getLanguage() and builds the shared ContentEditor
      toolbar so plugin actions (Markdown preview) appear in hop-web.
    - BaseGuiWidgets resolves static toolbar listeners with assignable
      parameter types (interface/facade), so 
previewMarkdown(IContentEditorWidget)
      works when the registered instance is ContentEditorWidget or RAP widgets.
    - Markdown preview is a static listener taking IContentEditorWidget;
      dedupe content-editor i18n keys.
    
    * issue #7549 : shared content-editor toolbar actions for Hop Web
    
    Move ContentEditor-Toolbar @GuiToolbarElement handlers to a ui-module
    GuiPlugin (ContentEditorActions) so Hop Web registers undo/copy/etc.
    without loading hop-ui-rcp.
    
    - Extend IContentEditorWidget with cut/paste/undo/redo
    - RCP ContentEditorWidget keeps context menu + keyboard shortcuts
    - RAP implements new ops (Text cut/paste; Monaco no-ops for now)
    - Exclude hop-ui-rcp from Hop Web image (fixes Tomcat 404 startup)
    - Skip ContentEditorWidget GuiPlugin scan on web as defense in depth
    
    * issue #7549 : Markdown preview on Hop Web via in-app Browser dialog
    
    Temp file:// URLs open on the client browser, so server-side temp HTML
    never displays in Hop Web. Use ShowBrowserDialog for the RAP case;
    desktop keeps the system browser + temp file path.
    
    Also surface InvocationTargetException from static toolbar listeners
    instead of silently falling through to a failed instance-method path.
    
    * issue #7549 : Monaco dark theme for Hop Web content editors
    
    Pass PropsUi dark mode into the Monaco remote widget as theme vs-dark
    or vs (same pattern as canvas themeId). monaco-editor.js honors the
    theme property on create and via setTheme.
    
    ---------
    
    Co-authored-by: Nicolas Adment <[email protected]>
---
 .gitignore                                         |    1 +
 docker/run-hop-web-local.sh                        |    7 +-
 docker/web.Dockerfile                              |    5 +
 .../transforms/types/JsonExplorerFileType.java     |    2 +
 .../transforms/types/BatExplorerFileType.java      |    2 +
 .../transforms/types/CsvExplorerFileType.java      |    2 +
 .../transforms/types/MarkDownExplorerFileType.java |    2 +
 .../types/MarkDownExplorerFileTypeHandler.java     |   95 +-
 .../transforms/types/PythonExplorerFileType.java   |    2 +
 .../transforms/types/ShellExplorerFileType.java    |    2 +
 .../transforms/types/TextExplorerFileType.java     |    2 +
 .../hop/ui/hopgui/ContentEditorFacadeImpl.java     |  163 ++-
 .../org/apache/hop/ui/hopgui/monaco-editor.js      |   20 +-
 .../hop/ui/hopgui/ContentEditorFacadeImpl.java     | 1034 +----------------
 .../apache/hop/ui/hopgui/ContentEditorWidget.java  | 1213 ++++++++++++++++++++
 .../org/apache/hop/ui/core/gui/BaseGuiWidgets.java |   60 +-
 .../core/widget/editor/IContentEditorWidget.java   |   24 +
 .../apache/hop/ui/hopgui/ContentEditorActions.java |  120 ++
 .../apache/hop/ui/hopgui/HopGuiEnvironment.java    |    6 +-
 .../perspective/explorer/ExplorerPerspective.java  |    5 +-
 .../file/types/sql/SqlExplorerFileType.java        |    2 +
 .../file/types/xml/XmlExplorerFileType.java        |    2 +
 .../ui/hopgui/messages/messages_en_US.properties   |   18 +-
 .../core/gui/BaseGuiWidgetsStaticListenerTest.java |   91 ++
 24 files changed, 1741 insertions(+), 1139 deletions(-)

diff --git a/.gitignore b/.gitignore
index f1160bc4c3..ef3a8dc1f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ release.properties
 pom.xml.releaseBackup
 *.iml
 .idea
+.junie
 bin
 *.class
 *.jar
diff --git a/docker/run-hop-web-local.sh b/docker/run-hop-web-local.sh
index 4962094206..773836d3f6 100755
--- a/docker/run-hop-web-local.sh
+++ b/docker/run-hop-web-local.sh
@@ -212,6 +212,11 @@ prepare_webapp() {
   log "Overlaying latest Hop module JARs (core, engine, ui, rap)"
   overlay_module_jars "${webapp}/WEB-INF/lib"
   overlay_module_jars "${client_lib}"
+
+  # Do not leave the desktop RCP UI fragment on the Hop Web classpath (see 
web.Dockerfile).
+  # Stripping here keeps the staged webapp correct even if Dockerfile is 
bypassed.
+  rm -f "${webapp}/WEB-INF/lib"/hop-ui-rcp-*.jar
+  log "Removed hop-ui-rcp from staged webapp WEB-INF/lib (Hop Web must use 
hop-ui-rap only)"
 }
 
 build_image() {
@@ -338,4 +343,4 @@ main() {
   run_container
 }
 
-main "$@"
\ No newline at end of file
+main "$@"
diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile
index 70fed1a1d9..5b768204a9 100644
--- a/docker/web.Dockerfile
+++ b/docker/web.Dockerfile
@@ -82,6 +82,11 @@ COPY ./assemblies/client/target/hop/plugins 
"${CATALINA_HOME}"/plugins
 COPY ./assemblies/client/target/hop/lib/jdbc/ "${CATALINA_HOME}"/jdbc-drivers
 COPY --chown=hop ./docker/resources/run-web.sh /tmp/
 
+# Desktop (RCP) UI fragment must not ship in Hop Web. Client lib/core is 
shared with the
+# desktop assembly and includes hop-ui-rcp; loading its GuiPlugins (e.g. 
ContentEditorWidget)
+# fails on RAP with NoClassDefFoundError for SWT types such as 
VerifyKeyListener.
+RUN rm -f "${CATALINA_HOME}"/webapps/ROOT/WEB-INF/lib/hop-ui-rcp-*.jar
+
 # Fix hop-config.json
 RUN sed -i 's/config\/projects/${HOP_CONFIG_FOLDER}\/projects/g' 
"${CATALINA_HOME}"/webapps/ROOT/config/hop-config.json
 
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
index fc19743704..c9d11197b3 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
@@ -48,6 +48,8 @@ public class JsonExplorerFileType extends 
BaseTextExplorerFileType<JsonExplorerF
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
index e3d1796686..e897142597 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
@@ -48,6 +48,8 @@ public class BatExplorerFileType extends 
BaseTextExplorerFileType<TextExplorerFi
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
index 0e6170ca53..30dbeb3669 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
@@ -48,6 +48,8 @@ public class CsvExplorerFileType extends 
BaseTextExplorerFileType<TextExplorerFi
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
index bf0112640f..169d6351bf 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
@@ -61,6 +61,8 @@ public class MarkDownExplorerFileType
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
index 00ec78346f..c44ea29ff8 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
@@ -23,17 +23,17 @@ import java.io.FileOutputStream;
 import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
-import org.apache.hop.core.Props;
 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.GuiToolbarElementFilter;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementType;
+import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
-import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
-import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.dialog.ShowBrowserDialog;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
 import org.apache.hop.ui.hopgui.ContentEditorFacade;
 import org.apache.hop.ui.hopgui.HopGui;
-import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import 
org.apache.hop.ui.hopgui.perspective.explorer.file.types.text.BaseTextExplorerFileTypeHandler;
@@ -45,19 +45,16 @@ import 
org.commonmark.ext.task.list.items.TaskListItemsExtension;
 import org.commonmark.node.Node;
 import org.commonmark.parser.Parser;
 import org.commonmark.renderer.html.HtmlRenderer;
-import org.eclipse.swt.SWT;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.widgets.Composite;
-import org.eclipse.swt.widgets.Control;
 
 /** How do we handle a markdown file in the file explorer perspective? */
-@GuiPlugin
+@GuiPlugin(name = "Markdown file type handler")
 public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHandler {
 
   private static final Class<?> PKG = MarkDownExplorerFileType.class;
 
-  public static final String TOOLBAR_PARENT_ID = 
"MarkDownExplorerFileTypeHandler-Toolbar";
+  protected static final String LANGUAGE = "markdown";
+
   public static final String TOOLBAR_ITEM_PREVIEW =
       "MarkDownExplorerFileTypeHandler-ToolBar-Preview";
 
@@ -72,41 +69,15 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
 
   @Override
   protected String getLanguageId() {
-    return "markdown";
+    return LANGUAGE;
   }
 
   @Override
   public void renderFile(Composite composite) {
-    // Create the toolbar container using the facade
-    IToolbarContainer toolBarContainer =
-        ToolbarFacade.createToolbarContainer(composite, SWT.WRAP | SWT.LEFT | 
SWT.HORIZONTAL);
-    Control toolBarControl = toolBarContainer.getControl();
-
-    // Create the toolbar widgets using GuiToolbarWidgets
-    GuiToolbarWidgets toolBarWidgets = new GuiToolbarWidgets();
-    toolBarWidgets.registerGuiPluginObject(this);
-    toolBarWidgets.createToolbarWidgets(toolBarContainer, TOOLBAR_PARENT_ID);
-
-    // Layout data for the toolbar control
-    FormData fdToolBar = new FormData();
-    fdToolBar.left = new FormAttachment(0, 0);
-    fdToolBar.right = new FormAttachment(100, 0);
-    fdToolBar.top = new FormAttachment(0, 0);
-    toolBarControl.setLayoutData(fdToolBar);
-    toolBarControl.pack();
-    PropsUi.setLook(toolBarControl, Props.WIDGET_STYLE_TOOLBAR);
 
-    // Create the standard editor widget
+    // Shared content-editor toolbar (incl. Markdown preview) is built inside 
the editor widget.
     editorWidget = ContentEditorFacade.createContentEditor(composite, 
getLanguageId());
-
-    // Position the editor widget below the toolbar
-    Control editorControl = editorWidget.getControl();
-    FormData fdEditor = new FormData();
-    fdEditor.left = new FormAttachment(0, 0);
-    fdEditor.right = new FormAttachment(100, 0);
-    fdEditor.top = new FormAttachment(toolBarControl, 0);
-    fdEditor.bottom = new FormAttachment(100, 0);
-    editorControl.setLayoutData(fdEditor);
+    
editorWidget.getControl().setLayoutData(FormDataBuilder.builder().fullSize().build());
 
     // If it's a new file, there's no need to reload it
     if (this.getFilename() != null) {
@@ -123,13 +94,23 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
         });
   }
 
+  @GuiToolbarElementFilter(parentId = 
IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID)
+  public static boolean showForMarkdownFileType(String itemId, Object 
guiPluginInstance) {
+    if (TOOLBAR_ITEM_PREVIEW.equals(itemId)
+        && guiPluginInstance instanceof IContentEditorWidget editor) {
+      return LANGUAGE.equals(editor.getLanguage());
+    }
+    return true;
+  }
+
   @GuiToolbarElement(
-      root = TOOLBAR_PARENT_ID,
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ITEM_PREVIEW,
       toolTip = "i18n::MarkDownFileTypeHandler.Preview.Tooltip",
       type = GuiToolbarElementType.BUTTON,
-      image = "ui/images/preview.svg")
-  public void previewMarkdown() {
+      image = "ui/images/preview.svg",
+      separator = true)
+  public static void previewMarkdown(IContentEditorWidget editorWidget) {
     try {
       String markdown = editorWidget.getText();
 
@@ -291,20 +272,30 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
       html.append(htmlContent);
       html.append("\n</body>\n</html>");
 
-      // Create a temporary file
-      File tempFile = File.createTempFile("markdown_preview_", ".html");
-      tempFile.deleteOnExit();
+      String fullHtml = html.toString();
 
-      // Write the HTML to the temp file
-      try (OutputStream outputStream = new FileOutputStream(tempFile)) {
-        outputStream.write(html.toString().getBytes(StandardCharsets.UTF_8));
+      // Hop Web: openUrl(file://...) points at the server temp path, which 
the client browser
+      // cannot read. Show the rendered HTML in an in-app Browser dialog 
instead.
+      // Desktop: keep writing a temp file and open it in the system browser.
+      if (EnvironmentUtils.getInstance().isWeb()) {
+        ShowBrowserDialog dialog =
+            new ShowBrowserDialog(
+                HopGui.getInstance().getActiveShell(), "Markdown preview", 
fullHtml);
+        dialog.open();
+      } else {
+        File tempFile = File.createTempFile("markdown_preview_", ".html");
+        tempFile.deleteOnExit();
+        try (OutputStream outputStream = new FileOutputStream(tempFile)) {
+          outputStream.write(fullHtml.getBytes(StandardCharsets.UTF_8));
+        }
+        EnvironmentUtils.getInstance().openUrl(tempFile.toURI().toString());
       }
-
-      // Open in browser
-      EnvironmentUtils.getInstance().openUrl(tempFile.toURI().toString());
     } catch (Exception e) {
       new ErrorDialog(
-          hopGui.getShell(), "Error", "Error generating or displaying Markdown 
preview", e);
+          HopGui.getInstance().getActiveShell(),
+          "Error",
+          "Error generating or displaying Markdown preview",
+          e);
     }
   }
 }
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
index 3d61448134..3cef1040c7 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
@@ -49,6 +49,8 @@ public class PythonExplorerFileType
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
index c95ae4f29c..c8ecd00dd0 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
@@ -48,6 +48,8 @@ public class ShellExplorerFileType extends 
BaseTextExplorerFileType<TextExplorer
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
index 094a47ebc2..95fbee298c 100644
--- 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
@@ -48,6 +48,8 @@ public class TextExplorerFileType extends 
BaseTextExplorerFileType<TextExplorerF
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
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 c1bdd5eb40..dd6aac7a6f 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
@@ -20,7 +20,10 @@ package org.apache.hop.ui.hopgui;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
 import org.eclipse.rap.json.JsonObject;
 import org.eclipse.rap.rwt.RWT;
@@ -30,16 +33,19 @@ import org.eclipse.rap.rwt.remote.RemoteObject;
 import org.eclipse.rap.rwt.widgets.WidgetUtil;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.ModifyListener;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Text;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Hop Web (RAP) implementation of the content editor. Uses Monaco Editor when 
available
  * (client-side JavaScript), with fallback to a plain Text widget.
+ *
+ * <p>Builds the shared {@link 
IContentEditorWidget#GUI_PLUGIN_TOOLBAR_PARENT_ID} toolbar so
+ * plugin-contributed actions (e.g. Markdown preview) appear in hop-web as 
well as desktop.
  */
 public class ContentEditorFacadeImpl extends ContentEditorFacade {
 
@@ -48,14 +54,15 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
   @Override
   protected IContentEditorWidget createContentEditorInternal(Composite parent, 
String languageId) {
     try {
-      Composite host = new Composite(parent, SWT.NONE);
+      Composite root = createRootComposite(parent);
+
+      // Editor host (Monaco parent) fills the area below the toolbar.
+      Composite host = new Composite(root, SWT.NONE);
       PropsUi.setLook(host);
-      FormData fd = new FormData();
-      fd.left = new FormAttachment(0, 0);
-      fd.right = new FormAttachment(100, 0);
-      fd.top = new FormAttachment(0, 0);
-      fd.bottom = new FormAttachment(100, 0);
-      host.setLayoutData(fd);
+      // Avoid a light flash before Monaco mounts when Hop Web is in dark mode
+      if (PropsUi.getInstance().isDarkMode()) {
+        
host.setBackground(host.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY));
+      }
 
       Connection connection = RWT.getUISession().getConnection();
       RemoteObject remoteObject = 
connection.createRemoteObject(MONACO_REMOTE_TYPE);
@@ -63,8 +70,14 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       remoteObject.set("self", remoteObject.getId());
       remoteObject.set("content", "");
       remoteObject.set("language", languageId != null ? languageId : 
"plaintext");
+      // Match Hop Web theme (/ui-dark → Monaco vs-dark), same idea as canvas 
themeId
+      remoteObject.set("theme", PropsUi.getInstance().isDarkMode() ? "vs-dark" 
: "vs");
+
+      RapMonacoEditorWidget widget =
+          new RapMonacoEditorWidget(root, host, remoteObject, languageId);
+      Control toolbar = addToolbar(root, widget);
+      
host.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
 
-      RapMonacoEditorWidget widget = new RapMonacoEditorWidget(host, 
remoteObject);
       remoteObject.setHandler(widget.getOperationHandler());
       remoteObject.listen("contentChanged", true);
       host.addListener(
@@ -79,34 +92,67 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       return widget;
     } catch (Exception e) {
       LogChannel.UI.logDebug("Monaco editor not available, using plain Text: " 
+ e.getMessage());
-      return createFallbackTextWidget(parent);
+      return createFallbackTextWidget(parent, languageId);
     }
   }
 
-  private static IContentEditorWidget createFallbackTextWidget(Composite 
parent) {
-    Text text = new Text(parent, SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL);
+  private static IContentEditorWidget createFallbackTextWidget(
+      Composite parent, String languageId) {
+    Composite root = createRootComposite(parent);
+
+    Text text = new Text(root, SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL | 
SWT.BORDER);
     PropsUi.setLook(text, Props.WIDGET_STYLE_FIXED);
-    FormData fd = new FormData();
-    fd.left = new FormAttachment(0, 0);
-    fd.right = new FormAttachment(100, 0);
-    fd.top = new FormAttachment(0, 0);
-    fd.bottom = new FormAttachment(100, 0);
-    text.setLayoutData(fd);
-    return new RapContentEditorWidget(text);
+
+    RapContentEditorWidget widget = new RapContentEditorWidget(root, text, 
languageId);
+    Control toolbar = addToolbar(root, widget);
+    
text.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
+    return widget;
+  }
+
+  private static Composite createRootComposite(Composite parent) {
+    Composite root = new Composite(parent, SWT.NONE);
+    root.setLayout(new FormLayout());
+    root.setLayoutData(FormDataBuilder.builder().fullSize().build());
+    PropsUi.setLook(root);
+    return root;
+  }
+
+  /**
+   * Create the shared content-editor toolbar on {@code root}, registering 
{@code widget} for
+   * toolbar filters and static listeners.
+   *
+   * @return the toolbar control
+   */
+  private static Control addToolbar(Composite root, IContentEditorWidget 
widget) {
+    IToolbarContainer toolbarContainer =
+        ToolbarFacade.createToolbarContainer(root, SWT.WRAP | SWT.RIGHT | 
SWT.HORIZONTAL);
+    Control toolbar = toolbarContainer.getControl();
+    toolbar.setLayoutData(FormDataBuilder.builder().top().fullWidth().build());
+    PropsUi.setLook(toolbar, Props.WIDGET_STYLE_TOOLBAR);
+
+    GuiToolbarWidgets toolbarWidgets = new GuiToolbarWidgets();
+    toolbarWidgets.registerGuiPluginObject(widget);
+    toolbarWidgets.createToolbarWidgets(
+        toolbarContainer, IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID);
+    toolbar.pack();
+    return toolbar;
   }
 
   private static class RapMonacoEditorWidget implements IContentEditorWidget {
 
-    private final Composite control;
+    private final Composite root;
     private final RemoteObject remoteObject;
     private volatile String cachedContent = "";
     private final java.util.List<ModifyListener> modifyListeners = new 
CopyOnWriteArrayList<>();
     private boolean suppressModify;
+    private volatile String languageId;
     private final AbstractOperationHandler operationHandler;
 
-    RapMonacoEditorWidget(Composite control, RemoteObject remoteObject) {
-      this.control = control;
+    RapMonacoEditorWidget(
+        Composite root, Composite host, RemoteObject remoteObject, String 
languageId) {
+      this.root = root;
       this.remoteObject = remoteObject;
+      this.languageId = languageId != null ? languageId : "";
       this.operationHandler =
           new AbstractOperationHandler() {
             @Override
@@ -119,13 +165,13 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
               if (suppressModify) {
                 return;
               }
-              Display display = control.getDisplay();
-              if (display == null || control.isDisposed()) {
+              Display display = host.getDisplay();
+              if (display == null || host.isDisposed()) {
                 return;
               }
               Runnable run =
                   () -> {
-                    if (control.isDisposed()) return;
+                    if (host.isDisposed()) return;
                     for (ModifyListener listener : modifyListeners) {
                       try {
                         listener.modifyText(null);
@@ -149,7 +195,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
 
     @Override
     public Control getControl() {
-      return control;
+      return root;
     }
 
     @Override
@@ -174,8 +220,14 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       }
     }
 
+    @Override
+    public @Nullable String getLanguage() {
+      return languageId;
+    }
+
     @Override
     public void setLanguage(String languageId) {
+      this.languageId = languageId != null ? languageId : "";
       remoteObject.set("language", languageId != null ? languageId : 
"plaintext");
     }
 
@@ -200,7 +252,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
 
     @Override
     public void selectAll() {
-      // Monaco handles selection on client; no-op or could call a remote 
method if we add one
+      // Monaco handles selection on client; no-op until a remote method is 
added
     }
 
     @Override
@@ -212,16 +264,40 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     public void copy() {
       // Monaco handles copy on client
     }
+
+    @Override
+    public void cut() {
+      // no-op for Monaco until clipboard remote ops exist
+    }
+
+    @Override
+    public void paste() {
+      // no-op for Monaco until clipboard remote ops exist
+    }
+
+    @Override
+    public void undo() {
+      // no-op for Monaco until undo remote ops exist
+    }
+
+    @Override
+    public void redo() {
+      // no-op for Monaco until redo remote ops exist
+    }
   }
 
   private static class RapContentEditorWidget implements IContentEditorWidget {
 
+    private final Composite root;
     private final Text text;
     private final java.util.List<ModifyListener> modifyListeners = new 
CopyOnWriteArrayList<>();
     private boolean suppressModify;
+    private volatile String languageId;
 
-    RapContentEditorWidget(Text text) {
+    RapContentEditorWidget(Composite root, Text text, String languageId) {
+      this.root = root;
       this.text = text;
+      this.languageId = languageId != null ? languageId : "";
       text.addModifyListener(
           e -> {
             if (suppressModify) return;
@@ -237,7 +313,7 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
 
     @Override
     public Control getControl() {
-      return text;
+      return root;
     }
 
     @Override
@@ -260,9 +336,14 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
       }
     }
 
+    @Override
+    public @Nullable String getLanguage() {
+      return languageId;
+    }
+
     @Override
     public void setLanguage(String languageId) {
-      // no-op for plain text
+      this.languageId = languageId != null ? languageId : "";
     }
 
     @Override
@@ -294,5 +375,25 @@ public class ContentEditorFacadeImpl extends 
ContentEditorFacade {
     public void copy() {
       text.copy();
     }
+
+    @Override
+    public void cut() {
+      text.cut();
+    }
+
+    @Override
+    public void paste() {
+      text.paste();
+    }
+
+    @Override
+    public void undo() {
+      // SWT Text has no standard undo API
+    }
+
+    @Override
+    public void redo() {
+      // SWT Text has no standard redo API
+    }
   }
 }
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 b12bb23302..61e8e87a4e 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
@@ -55,6 +55,15 @@
     return map[String(lang).toLowerCase()] || "plaintext";
   }
 
+  /** Monaco built-in themes: vs (light), vs-dark, hc-black */
+  function normalizeTheme(theme) {
+    if (!theme) return "vs";
+    var t = String(theme).toLowerCase();
+    if (t === "vs-dark" || t === "dark") return "vs-dark";
+    if (t === "hc-black" || t === "hc") return "hc-black";
+    return "vs";
+  }
+
   rap.registerTypeHandler("hop.MonacoEditor", {
     factory: function(properties) {
       return new hop.MonacoEditor(properties);
@@ -71,7 +80,7 @@
         widget._container.parentNode.removeChild(widget._container);
       }
     },
-    properties: [ "content", "language", "readOnly" ],
+    properties: [ "content", "language", "readOnly", "theme" ],
     events: [ "contentChanged" ]
   });
 
@@ -81,6 +90,7 @@
     this._content = properties.content != null ? properties.content : "";
     this._language = properties.language != null ? properties.language : 
"plaintext";
     this._readOnly = properties.readOnly === true;
+    this._theme = normalizeTheme(properties.theme);
     this._editor = null;
     this._container = null;
     this._parentId = properties.parent;
@@ -126,6 +136,13 @@
       }
     },
 
+    setTheme: function(theme) {
+      this._theme = normalizeTheme(theme);
+      if (this._editor && window.monaco && window.monaco.editor) {
+        window.monaco.editor.setTheme(this._theme);
+      }
+    },
+
     _notifyServer: function() {
       if (this._destroyed || !this._editor || this._readOnly) return;
       var value = this._editor.getValue();
@@ -193,6 +210,7 @@
         self._editor = window.monaco.editor.create(container, {
           value: self._content,
           language: langForEditor,
+          theme: self._theme,
           readOnly: self._readOnly,
           automaticLayout: true,
           scrollBeyondLastLine: false,
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
index 9c63b195d3..4a5d4ab6c9 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
@@ -17,58 +17,8 @@
 
 package org.apache.hop.ui.hopgui;
 
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Deque;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CopyOnWriteArrayList;
-import org.apache.hop.ui.core.PropsUi;
-import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
-import org.eclipse.jface.text.DocumentEvent;
-import org.eclipse.jface.text.IDocument;
-import org.eclipse.jface.text.IDocumentExtension3;
-import org.eclipse.jface.text.IDocumentListener;
-import org.eclipse.jface.text.ITextViewerExtension5;
-import org.eclipse.jface.text.Position;
-import org.eclipse.jface.text.rules.FastPartitioner;
-import org.eclipse.jface.text.rules.RuleBasedPartitionScanner;
-import org.eclipse.jface.text.rules.Token;
-import org.eclipse.jface.text.source.Annotation;
-import org.eclipse.jface.text.source.AnnotationModel;
-import org.eclipse.jface.text.source.AnnotationRulerColumn;
-import org.eclipse.jface.text.source.CompositeRuler;
-import org.eclipse.jface.text.source.IAnnotationAccess;
-import org.eclipse.jface.text.source.IAnnotationAccessExtension;
-import org.eclipse.jface.text.source.LineNumberRulerColumn;
-import org.eclipse.jface.text.source.SourceViewer;
-import org.eclipse.jface.text.source.SourceViewerConfiguration;
-import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
-import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel;
-import org.eclipse.jface.text.source.projection.ProjectionViewer;
-import org.eclipse.swt.SWT;
-import org.eclipse.swt.custom.CaretListener;
-import org.eclipse.swt.custom.StyleRange;
-import org.eclipse.swt.custom.StyledText;
-import org.eclipse.swt.custom.VerifyKeyListener;
-import org.eclipse.swt.events.ModifyListener;
-import org.eclipse.swt.events.MouseAdapter;
-import org.eclipse.swt.events.MouseEvent;
-import org.eclipse.swt.events.PaintEvent;
-import org.eclipse.swt.events.PaintListener;
-import org.eclipse.swt.events.VerifyEvent;
-import org.eclipse.swt.graphics.Color;
-import org.eclipse.swt.graphics.GC;
-import org.eclipse.swt.graphics.Rectangle;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
-import org.eclipse.swt.layout.FormLayout;
-import org.eclipse.swt.widgets.Canvas;
 import org.eclipse.swt.widgets.Composite;
-import org.eclipse.swt.widgets.Control;
-import org.eclipse.swt.widgets.Display;
 
 /**
  * Desktop (RCP) implementation of the content editor using native SWT: 
Eclipse JFace SourceViewer
@@ -76,990 +26,8 @@ import org.eclipse.swt.widgets.Display;
  * tags.
  */
 public class ContentEditorFacadeImpl extends ContentEditorFacade {
-
   @Override
   protected IContentEditorWidget createContentEditorInternal(Composite parent, 
String languageId) {
-    Composite container = new Composite(parent, SWT.NONE);
-    PropsUi.setLook(container);
-    container.setLayout(new FormLayout());
-    FormData fd = new FormData();
-    fd.left = new FormAttachment(0, 0);
-    fd.right = new FormAttachment(100, 0);
-    fd.top = new FormAttachment(0, 0);
-    fd.bottom = new FormAttachment(100, 0);
-    container.setLayoutData(fd);
-
-    int styles = SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL | SWT.BORDER;
-    CompositeRuler ruler = new CompositeRuler();
-    ProjectionViewer sourceViewer = new ProjectionViewer(container, ruler, 
null, false, styles);
-    sourceViewer.setEditable(true);
-    org.eclipse.jface.text.Document document = new 
org.eclipse.jface.text.Document();
-    RuleBasedPartitionScanner partitionScanner = new 
RuleBasedPartitionScanner();
-    partitionScanner.setDefaultReturnToken(new 
Token(IDocument.DEFAULT_CONTENT_TYPE));
-    FastPartitioner partitioner = new FastPartitioner(partitionScanner, new 
String[0]);
-    partitioner.connect(document);
-    ((IDocumentExtension3) document)
-        .setDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING, 
partitioner);
-    sourceViewer.setDocument(document, new AnnotationModel());
-
-    SourceViewerConfiguration config = 
RuleBasedSourceViewerConfiguration.create(languageId);
-    if (config != null) {
-      sourceViewer.configure(config);
-    }
-    sourceViewer.invalidateTextPresentation();
-
-    // Enable projection (folding) without ProjectionSupport so we control the 
ruler column
-    sourceViewer.doOperation(ProjectionViewer.TOGGLE);
-
-    // Line numbers first (index 0), then fold column (index 1) with explicit 
width
-    LineNumberRulerColumn lineNumberColumn = new LineNumberRulerColumn();
-    ruler.addDecorator(0, lineNumberColumn);
-
-    ProjectionAnnotationModel projModel = 
sourceViewer.getProjectionAnnotationModel();
-    if (projModel != null) {
-      FoldingAnnotationAccess foldAccess = new FoldingAnnotationAccess();
-      AnnotationRulerColumn foldColumn = new AnnotationRulerColumn(projModel, 
20, foldAccess);
-      foldColumn.addAnnotationType(ProjectionAnnotation.TYPE);
-      ruler.addDecorator(1, foldColumn);
-
-      Control foldControl = foldColumn.getControl();
-      if (foldControl != null) {
-        foldControl.addMouseListener(
-            new MouseAdapter() {
-              @Override
-              public void mouseUp(MouseEvent e) {
-                int line = foldColumn.toDocumentLineNumber(e.y);
-                if (line < 0) return;
-                IDocument doc = sourceViewer.getDocument();
-                if (doc == null) return;
-                java.util.Iterator<Annotation> it = 
projModel.getAnnotationIterator();
-                while (it.hasNext()) {
-                  Annotation ann = it.next();
-                  if (ann instanceof ProjectionAnnotation) {
-                    Position pos = projModel.getPosition(ann);
-                    if (pos != null) {
-                      try {
-                        int annLine = doc.getLineOfOffset(pos.getOffset());
-                        if (annLine == line) {
-                          
projModel.toggleExpansionState((ProjectionAnnotation) ann);
-                          return;
-                        }
-                      } catch (org.eclipse.jface.text.BadLocationException 
ignored) {
-                        // ignore
-                      }
-                    }
-                  }
-                }
-              }
-            });
-      }
-    }
-
-    applyFontFromHop(sourceViewer);
-    org.eclipse.swt.graphics.Font editorFont = 
sourceViewer.getTextWidget().getFont();
-    if (editorFont != null && !editorFont.isDisposed()) {
-      lineNumberColumn.setFont(editorFont);
-    }
-    boolean dark = PropsUi.getInstance().isDarkMode();
-    if (dark) {
-      org.eclipse.swt.graphics.Color lineNumFg =
-          new org.eclipse.swt.graphics.Color(container.getDisplay(), 130, 130, 
130);
-      org.eclipse.swt.graphics.Color lineNumBg =
-          new org.eclipse.swt.graphics.Color(container.getDisplay(), 40, 40, 
40);
-      lineNumberColumn.setForeground(lineNumFg);
-      lineNumberColumn.setBackground(lineNumBg);
-    } else {
-      org.eclipse.swt.graphics.Color lineNumFg =
-          new org.eclipse.swt.graphics.Color(container.getDisplay(), 120, 120, 
120);
-      lineNumberColumn.setForeground(lineNumFg);
-    }
-    sourceViewer.getTextWidget().setTabs(4);
-
-    Control viewerControl = sourceViewer.getControl();
-    FormData fdViewer = new FormData();
-    fdViewer.left = new FormAttachment(0, 0);
-    fdViewer.right = new FormAttachment(100, 0);
-    fdViewer.top = new FormAttachment(0, 0);
-    fdViewer.bottom = new FormAttachment(100, 0);
-    viewerControl.setLayoutData(fdViewer);
-
-    SwtContentEditorWidget widget = new SwtContentEditorWidget(container, 
sourceViewer, languageId);
-    installBracketAndXmlSupport(widget);
-    return widget;
-  }
-
-  /** Installs bracket highlighting and XML auto-closing when language is xml. 
*/
-  private static void installBracketAndXmlSupport(SwtContentEditorWidget 
widget) {
-    widget.installBracketHighlighting();
-    widget.installFolding();
-    widget.installBlockGuidePainter();
-    widget.installCollapsePlaceholderPainter();
-    widget.installXmlAutoClose();
-  }
-
-  private static void applyFontFromHop(SourceViewer sourceViewer) {
-    try {
-      org.eclipse.swt.graphics.Font swtFont = 
GuiResource.getInstance().getFontFixed();
-      if (swtFont != null && !swtFont.isDisposed()) {
-        sourceViewer.getTextWidget().setFont(swtFont);
-      }
-    } catch (Exception e) {
-      // GuiResource or font not available
-    }
-  }
-
-  /** Kept for API compatibility; rule-based config uses languageId directly. 
*/
-  static String mapLanguageToScope(String languageId) {
-    return languageId;
-  }
-
-  /**
-   * Annotation access for the fold ruler column: paints chevron icons and 
reports annotation
-   * metadata to the {@link AnnotationRulerColumn}.
-   */
-  private static class FoldingAnnotationAccess
-      implements IAnnotationAccess, IAnnotationAccessExtension {
-
-    @Override
-    public Object getType(Annotation annotation) {
-      return annotation.getType();
-    }
-
-    @Override
-    public boolean isMultiLine(Annotation annotation) {
-      return true;
-    }
-
-    @Override
-    public boolean isTemporary(Annotation annotation) {
-      return true;
-    }
-
-    @Override
-    public String getTypeLabel(Annotation annotation) {
-      return annotation.getText() != null ? annotation.getText() : "";
-    }
-
-    @Override
-    public int getLayer(Annotation annotation) {
-      return 0;
-    }
-
-    @Override
-    public Object[] getSupertypes(Object annotationType) {
-      return new Object[0];
-    }
-
-    @Override
-    public boolean isSubtype(Object annotationType, Object potentialSupertype) 
{
-      return annotationType != null && 
annotationType.equals(potentialSupertype);
-    }
-
-    @Override
-    public boolean isPaintable(Annotation annotation) {
-      return annotation instanceof ProjectionAnnotation;
-    }
-
-    @Override
-    public void paint(Annotation annotation, GC gc, Canvas canvas, Rectangle 
rectangle) {
-      if (!(annotation instanceof ProjectionAnnotation)) return;
-      ProjectionAnnotation pa = (ProjectionAnnotation) annotation;
-      boolean collapsed = pa.isCollapsed();
-      int x = rectangle.x;
-      int y = rectangle.y;
-      int w = rectangle.width;
-      int lineHeight = 14;
-      int h = Math.min(rectangle.height, lineHeight);
-      if (w < 5 || h < 5) return;
-
-      
gc.setForeground(canvas.getDisplay().getSystemColor(SWT.COLOR_WIDGET_FOREGROUND));
-      gc.setAntialias(SWT.ON);
-      gc.setLineWidth(1);
-      int pad = 2;
-      int cx = x + w / 2;
-      int cy = y + h / 2;
-      if (collapsed) {
-        // Right-pointing chevron: >
-        int left = x + pad;
-        int right = x + w - pad;
-        int top = y + pad;
-        int bottom = y + h - pad;
-        gc.drawLine(left, top, right, cy);
-        gc.drawLine(right, cy, left, bottom);
-      } else {
-        // Down-pointing chevron: v
-        int left = x + pad;
-        int right = x + w - pad;
-        int top = y + pad;
-        int bottom = y + h - pad;
-        gc.drawLine(left, top, cx, bottom);
-        gc.drawLine(cx, bottom, right, top);
-      }
-    }
-  }
-
-  private static final class SwtContentEditorWidget implements 
IContentEditorWidget {
-
-    private final Composite control;
-    private final SourceViewer sourceViewer;
-    private final List<ModifyListener> modifyListeners = new 
CopyOnWriteArrayList<>();
-    private volatile boolean suppressModify;
-    private volatile String languageId;
-
-    SwtContentEditorWidget(Composite control, SourceViewer sourceViewer, 
String languageId) {
-      this.control = control;
-      this.sourceViewer = sourceViewer;
-      this.languageId = languageId != null ? languageId : "";
-      sourceViewer
-          .getDocument()
-          .addDocumentListener(
-              new IDocumentListener() {
-                @Override
-                public void documentAboutToBeChanged(DocumentEvent event) {}
-
-                @Override
-                public void documentChanged(DocumentEvent event) {
-                  fireModify();
-                }
-
-                private void fireModify() {
-                  if (suppressModify) {
-                    return;
-                  }
-                  Display display = control.getDisplay();
-                  if (display == null || control.isDisposed()) {
-                    return;
-                  }
-                  display.asyncExec(
-                      () -> {
-                        if (control.isDisposed() || suppressModify) {
-                          return;
-                        }
-                        for (ModifyListener listener : new 
ArrayList<>(modifyListeners)) {
-                          try {
-                            listener.modifyText(null);
-                          } catch (Exception ignored) {
-                            // ignore
-                          }
-                        }
-                      });
-                }
-              });
-    }
-
-    @Override
-    public Control getControl() {
-      return control;
-    }
-
-    @Override
-    public String getText() {
-      IDocument doc = sourceViewer.getDocument();
-      return doc != null ? doc.get() : "";
-    }
-
-    @Override
-    public void setText(String text) {
-      doSetText(text != null ? text : "", false);
-    }
-
-    @Override
-    public void setTextSuppressModify(String text) {
-      doSetText(text != null ? text : "", true);
-    }
-
-    private void doSetText(String text, boolean suppress) {
-      if (suppress) {
-        suppressModify = true;
-      }
-      try {
-        IDocument doc = sourceViewer.getDocument();
-        if (doc != null) {
-          doc.set(text);
-        }
-        sourceViewer.setSelectedRange(0, 0);
-        sourceViewer.invalidateTextPresentation();
-      } finally {
-        if (suppress) {
-          suppressModify = false;
-        }
-      }
-    }
-
-    @Override
-    public void setLanguage(String languageId) {
-      this.languageId = languageId != null ? languageId : "";
-      SourceViewerConfiguration config = 
RuleBasedSourceViewerConfiguration.create(languageId);
-      if (config != null) {
-        sourceViewer.unconfigure();
-        sourceViewer.configure(config);
-        sourceViewer.invalidateTextPresentation();
-      }
-      updateFoldingRegions();
-    }
-
-    @Override
-    public void setReadOnly(boolean readOnly) {
-      sourceViewer.setEditable(!readOnly);
-    }
-
-    @Override
-    public void addModifyListener(ModifyListener listener) {
-      if (listener != null) {
-        modifyListeners.add(listener);
-      }
-    }
-
-    @Override
-    public void removeModifyListener(ModifyListener listener) {
-      if (listener != null) {
-        modifyListeners.remove(listener);
-      }
-    }
-
-    @Override
-    public void selectAll() {
-      IDocument doc = sourceViewer.getDocument();
-      if (doc != null) {
-        sourceViewer.setSelectedRange(0, doc.getLength());
-      }
-    }
-
-    @Override
-    public void unselectAll() {
-      sourceViewer.setSelectedRange(0, 0);
-    }
-
-    @Override
-    public void copy() {
-      sourceViewer.doOperation(SourceViewer.COPY);
-    }
-
-    private static final char[] OPEN_BRACKETS = {'(', '[', '{'};
-    private static final char[] CLOSE_BRACKETS = {')', ']', '}'};
-    private int[] highlightedPositions;
-    private List<ProjectionAnnotation> currentFoldAnnotations = new 
ArrayList<>();
-    private Runnable pendingFoldUpdate;
-
-    void installBracketHighlighting() {
-      StyledText st = sourceViewer.getTextWidget();
-      Color hlBg =
-          new Color(
-              st.getDisplay(),
-              PropsUi.getInstance().isDarkMode()
-                  ? new org.eclipse.swt.graphics.RGB(80, 80, 80)
-                  : new org.eclipse.swt.graphics.RGB(220, 220, 220));
-      st.addDisposeListener(e -> hlBg.dispose());
-
-      CaretListener caretListener =
-          event -> {
-            clearBracketHighlight(st);
-            String text = st.getText();
-            int offset = event.caretOffset;
-            int[] enclosing = findEnclosingBrackets(text, offset);
-            if (enclosing != null) {
-              applyBracketHighlight(st, enclosing[0], enclosing[1], hlBg);
-            }
-          };
-      st.addCaretListener(caretListener);
-    }
-
-    private void clearBracketHighlight(StyledText st) {
-      if (highlightedPositions == null) return;
-      for (int pos : highlightedPositions) {
-        if (pos < 0 || pos >= st.getCharCount()) continue;
-        StyleRange existing = st.getStyleRangeAtOffset(pos);
-        if (existing != null) {
-          existing.background = null;
-          st.setStyleRange(existing);
-        }
-      }
-      highlightedPositions = null;
-    }
-
-    private void applyBracketHighlight(StyledText st, int pos1, int pos2, 
Color bg) {
-      highlightedPositions = new int[] {pos1, pos2};
-      for (int pos : highlightedPositions) {
-        if (pos < 0 || pos >= st.getCharCount()) continue;
-        StyleRange sr = st.getStyleRangeAtOffset(pos);
-        if (sr == null) {
-          sr = new StyleRange();
-          sr.start = pos;
-          sr.length = 1;
-        } else {
-          sr = (StyleRange) sr.clone();
-        }
-        sr.background = bg;
-        st.setStyleRange(sr);
-      }
-    }
-
-    /**
-     * Finds the innermost enclosing bracket pair around the given offset. 
Scans forward from the
-     * start of the text, tracking a bracket stack. The top of the stack at 
the cursor position is
-     * the enclosing open bracket; its forward match gives the close bracket.
-     *
-     * @return int[]{openPos, closePos} or null if not inside any brackets
-     */
-    private static int[] findEnclosingBrackets(String text, int offset) {
-      Deque<int[]> stack = new ArrayDeque<>();
-      boolean inStr = false;
-
-      for (int i = 0; i < offset && i < text.length(); i++) {
-        char c = text.charAt(i);
-        if (inStr) {
-          if (c == '\\' && i + 1 < text.length()) {
-            i++;
-            continue;
-          }
-          if (c == '"') inStr = false;
-          continue;
-        }
-        if (c == '"') {
-          inStr = true;
-          continue;
-        }
-
-        int openIdx = indexOf(OPEN_BRACKETS, c);
-        if (openIdx >= 0) {
-          stack.push(new int[] {i, openIdx});
-        } else {
-          int closeIdx = indexOf(CLOSE_BRACKETS, c);
-          if (closeIdx >= 0 && !stack.isEmpty() && stack.peek()[1] == 
closeIdx) {
-            stack.pop();
-          }
-        }
-      }
-
-      // Walk the stack from innermost (top) outward, find one whose close 
bracket is past offset
-      while (!stack.isEmpty()) {
-        int[] top = stack.pop();
-        int openPos = top[0];
-        int closePos =
-            findMatchForward(text, openPos, OPEN_BRACKETS[top[1]], 
CLOSE_BRACKETS[top[1]]);
-        if (closePos >= offset) {
-          return new int[] {openPos, closePos};
-        }
-      }
-      return null;
-    }
-
-    /** Finds the matching close bracket for an open bracket at pos, scanning 
forward. */
-    private static int findMatchForward(String text, int pos, char open, char 
close) {
-      int depth = 0;
-      boolean inStr = false;
-      for (int i = pos; i < text.length(); i++) {
-        char c = text.charAt(i);
-        if (inStr) {
-          if (c == '\\' && i + 1 < text.length()) {
-            i++;
-            continue;
-          }
-          if (c == '"') inStr = false;
-          continue;
-        }
-        if (c == '"') {
-          inStr = true;
-          continue;
-        }
-        if (c == open) depth++;
-        else if (c == close) depth--;
-        if (depth == 0) return i;
-      }
-      return -1;
-    }
-
-    private static int indexOf(char[] arr, char c) {
-      for (int i = 0; i < arr.length; i++) {
-        if (arr[i] == c) return i;
-      }
-      return -1;
-    }
-
-    void installFolding() {
-      if (!(sourceViewer instanceof ProjectionViewer)) return;
-
-      sourceViewer
-          .getDocument()
-          .addDocumentListener(
-              new IDocumentListener() {
-                @Override
-                public void documentAboutToBeChanged(DocumentEvent event) {}
-
-                @Override
-                public void documentChanged(DocumentEvent event) {
-                  scheduleFoldUpdate();
-                }
-              });
-    }
-
-    /** Paints vertical block guide lines (indent/bracket scope) like VS 
Code/IntelliJ. */
-    void installBlockGuidePainter() {
-      StyledText st = sourceViewer.getTextWidget();
-      boolean dark = PropsUi.getInstance().isDarkMode();
-      org.eclipse.swt.graphics.Color guideColor =
-          new org.eclipse.swt.graphics.Color(
-              st.getDisplay(), dark ? 60 : 200, dark ? 60 : 200, dark ? 60 : 
200);
-      st.addDisposeListener(e -> guideColor.dispose());
-
-      final int guideWidthPx = 10;
-      final boolean useModelMapping = sourceViewer instanceof 
ITextViewerExtension5;
-
-      st.addPaintListener(
-          new PaintListener() {
-            @Override
-            public void paintControl(PaintEvent e) {
-              if (!"json".equalsIgnoreCase(languageId) && 
!"xml".equalsIgnoreCase(languageId)) {
-                return;
-              }
-              String text = getText();
-              if (text.isEmpty()) return;
-
-              List<Position> regions =
-                  "json".equalsIgnoreCase(languageId)
-                      ? computeJsonFoldRegions(text)
-                      : computeXmlFoldRegions(text);
-              if (regions.isEmpty()) return;
-
-              // Convert to (startLine+1, closingLine) blocks — guides show 
between open/close,
-              // not on the opening or closing line itself.
-              // Column = nesting depth (how many other blocks contain this 
one).
-              List<int[]> rawBlocks = new ArrayList<>();
-              for (Position pos : regions) {
-                int startLine = lineOfOffset(text, pos.offset);
-                int closingLine = lineOfOffset(text, pos.offset + pos.length - 
1);
-                if (closingLine > startLine + 1) {
-                  rawBlocks.add(new int[] {startLine + 1, closingLine});
-                }
-              }
-              // Deduplicate identical ranges
-              Map<Long, int[]> uniqueBlocks = new HashMap<>();
-              for (int[] b : rawBlocks) {
-                long key = ((long) b[0] << 32) | (b[1] & 0xFFFFFFFFL);
-                uniqueBlocks.putIfAbsent(key, b);
-              }
-              // Compute depth for each block: count how many other blocks 
strictly contain it
-              List<int[]> blocks = new ArrayList<>();
-              for (int[] b : uniqueBlocks.values()) {
-                int depth = 0;
-                for (int[] other : uniqueBlocks.values()) {
-                  if (other[0] <= b[0] && other[1] >= b[1] && other != b) {
-                    depth++;
-                  }
-                }
-                blocks.add(new int[] {b[0], b[1], depth});
-              }
-
-              int lineHeight = st.getLineHeight();
-              int topIndex = st.getTopIndex();
-              int visibleCount =
-                  Math.min(
-                      st.getLineCount() - topIndex,
-                      (st.getClientArea().height + lineHeight - 1) / 
lineHeight);
-
-              e.gc.setForeground(guideColor);
-              e.gc.setLineWidth(1);
-              e.gc.setLineStyle(SWT.LINE_SOLID);
-
-              ITextViewerExtension5 ext5 =
-                  useModelMapping ? (ITextViewerExtension5) sourceViewer : 
null;
-
-              for (int i = 0; i < visibleCount; i++) {
-                int widgetLine = topIndex + i;
-                int modelLine = (ext5 != null) ? 
ext5.widgetLine2ModelLine(widgetLine) : widgetLine;
-                if (modelLine < 0) continue;
-                int y = i * lineHeight;
-                for (int[] block : blocks) {
-                  int start = block[0];
-                  int endExcl = block[1];
-                  int col = block[2];
-                  if (modelLine >= start && modelLine < endExcl) {
-                    int x = 2 + col * guideWidthPx;
-                    e.gc.drawLine(x, y, x, y + lineHeight);
-                  }
-                }
-              }
-            }
-          });
-    }
-
-    /** 0-based line number for the given offset (counts '\n' before offset). 
*/
-    private static int lineOfOffset(String text, int offset) {
-      int line = 0;
-      for (int i = 0; i < offset && i < text.length(); i++) {
-        if (text.charAt(i) == '\n') line++;
-      }
-      return line;
-    }
-
-    /** Paints "..." at the end of the first line of each collapsed fold (like 
VS Code). */
-    void installCollapsePlaceholderPainter() {
-      if (!(sourceViewer instanceof ProjectionViewer)) return;
-      if (!(sourceViewer instanceof ITextViewerExtension5)) return;
-
-      StyledText st = sourceViewer.getTextWidget();
-      boolean dark = PropsUi.getInstance().isDarkMode();
-      org.eclipse.swt.graphics.Color placeholderColor =
-          new org.eclipse.swt.graphics.Color(
-              st.getDisplay(), dark ? 140 : 120, dark ? 140 : 120, dark ? 140 
: 120);
-      st.addDisposeListener(e -> placeholderColor.dispose());
-
-      st.addPaintListener(
-          e1 -> {
-            ProjectionViewer pv = (ProjectionViewer) sourceViewer;
-            ProjectionAnnotationModel model = 
pv.getProjectionAnnotationModel();
-            if (model == null) return;
-            IDocument doc = sourceViewer.getDocument();
-            if (doc == null) return;
-            ITextViewerExtension5 ext5 = (ITextViewerExtension5) sourceViewer;
-
-            e1.gc.setForeground(placeholderColor);
-            e1.gc.setFont(st.getFont());
-
-            java.util.Iterator<Annotation> it = model.getAnnotationIterator();
-            while (it.hasNext()) {
-              Annotation ann = it.next();
-              if (!(ann instanceof ProjectionAnnotation)) continue;
-              if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
-              Position pos = model.getPosition(ann);
-              if (pos == null) continue;
-              int modelLine;
-              try {
-                modelLine = doc.getLineOfOffset(pos.offset);
-              } catch (org.eclipse.jface.text.BadLocationException ex) {
-                continue;
-              }
-              int widgetLine = ext5.modelLine2WidgetLine(modelLine);
-              if (widgetLine < 0 || widgetLine >= st.getLineCount()) continue;
-              int lineEndOffset;
-              try {
-                int lineStart = st.getOffsetAtLine(widgetLine);
-                int lineLen = st.getLine(widgetLine).length();
-                lineEndOffset = lineStart + lineLen;
-              } catch (Exception ex) {
-                continue;
-              }
-              org.eclipse.swt.graphics.Point pt = 
st.getLocationAtOffset(lineEndOffset);
-              if (pt != null) {
-                e1.gc.drawText("...", pt.x + 4, pt.y);
-              }
-            }
-          });
-
-      // Click on "..." placeholder expands the collapsed fold
-      final int placeholderClickWidth = 24;
-      st.addMouseListener(
-          new org.eclipse.swt.events.MouseAdapter() {
-            @Override
-            public void mouseUp(org.eclipse.swt.events.MouseEvent e) {
-              if (e.button != 1) return;
-              ProjectionViewer pv = (ProjectionViewer) sourceViewer;
-              ProjectionAnnotationModel model = 
pv.getProjectionAnnotationModel();
-              if (model == null) return;
-              IDocument doc = sourceViewer.getDocument();
-              if (doc == null) return;
-              ITextViewerExtension5 ext5 = (ITextViewerExtension5) 
sourceViewer;
-
-              int lineHeight = st.getLineHeight();
-              java.util.Iterator<Annotation> it = 
model.getAnnotationIterator();
-              while (it.hasNext()) {
-                Annotation ann = it.next();
-                if (!(ann instanceof ProjectionAnnotation)) continue;
-                if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
-                Position pos = model.getPosition(ann);
-                if (pos == null) continue;
-                int modelLine;
-                try {
-                  modelLine = doc.getLineOfOffset(pos.offset);
-                } catch (org.eclipse.jface.text.BadLocationException ex) {
-                  continue;
-                }
-                int widgetLine = ext5.modelLine2WidgetLine(modelLine);
-                if (widgetLine < 0 || widgetLine >= st.getLineCount()) 
continue;
-                int lineEndOffset;
-                try {
-                  int lineStart = st.getOffsetAtLine(widgetLine);
-                  int lineLen = st.getLine(widgetLine).length();
-                  lineEndOffset = lineStart + lineLen;
-                } catch (Exception ex) {
-                  continue;
-                }
-                org.eclipse.swt.graphics.Point pt = 
st.getLocationAtOffset(lineEndOffset);
-                if (pt == null) continue;
-                int x1 = pt.x + 4;
-                int y1 = pt.y;
-                if (e.x >= x1
-                    && e.x <= x1 + placeholderClickWidth
-                    && e.y >= y1
-                    && e.y < y1 + lineHeight) {
-                  model.expand(ann);
-                  return;
-                }
-              }
-            }
-          });
-    }
-
-    private void scheduleFoldUpdate() {
-      Display display = control.getDisplay();
-      if (display == null || display.isDisposed()) return;
-      if (pendingFoldUpdate != null) {
-        display.timerExec(-1, pendingFoldUpdate);
-      }
-      pendingFoldUpdate =
-          () -> {
-            if (!control.isDisposed()) {
-              updateFoldingRegions();
-            }
-            pendingFoldUpdate = null;
-          };
-      display.timerExec(500, pendingFoldUpdate);
-    }
-
-    private void updateFoldingRegions() {
-      if (!(sourceViewer instanceof ProjectionViewer)) return;
-      ProjectionViewer pv = (ProjectionViewer) sourceViewer;
-      ProjectionAnnotationModel model = pv.getProjectionAnnotationModel();
-      if (model == null) return;
-
-      if (pendingFoldUpdate != null && !control.isDisposed()) {
-        control.getDisplay().timerExec(-1, pendingFoldUpdate);
-        pendingFoldUpdate = null;
-      }
-
-      String text = getText();
-      List<Position> regions;
-      if ("json".equalsIgnoreCase(languageId)) {
-        regions = computeJsonFoldRegions(text);
-      } else if ("xml".equalsIgnoreCase(languageId)) {
-        regions = computeXmlFoldRegions(text);
-      } else {
-        regions = List.of();
-      }
-
-      Annotation[] deletions = currentFoldAnnotations.toArray(new 
Annotation[0]);
-      Map<ProjectionAnnotation, Position> additions = new HashMap<>();
-      List<ProjectionAnnotation> newAnnotations = new ArrayList<>();
-      for (Position pos : regions) {
-        ProjectionAnnotation annotation = new ProjectionAnnotation();
-        additions.put(annotation, pos);
-        newAnnotations.add(annotation);
-      }
-
-      model.modifyAnnotations(deletions, additions, null);
-      currentFoldAnnotations = newAnnotations;
-    }
-
-    private static List<Position> computeJsonFoldRegions(String text) {
-      List<Position> regions = new ArrayList<>();
-      Deque<Integer> stack = new ArrayDeque<>();
-      boolean inString = false;
-
-      for (int i = 0; i < text.length(); i++) {
-        char c = text.charAt(i);
-        if (inString) {
-          if (c == '\\' && i + 1 < text.length()) {
-            i++;
-            continue;
-          }
-          if (c == '"') inString = false;
-          continue;
-        }
-        if (c == '"') {
-          inString = true;
-          continue;
-        }
-        if (c == '{' || c == '[') {
-          stack.push(i);
-        } else if (c == '}' || c == ']') {
-          if (!stack.isEmpty()) {
-            int start = stack.pop();
-            if (spanMultipleLines(text, start, i)) {
-              regions.add(new Position(start, i - start + 1));
-            }
-          }
-        }
-      }
-      return regions;
-    }
-
-    private static List<Position> computeXmlFoldRegions(String text) {
-      List<Position> regions = new ArrayList<>();
-
-      // Fold multi-line comments
-      int idx = 0;
-      while (idx < text.length()) {
-        int cs = text.indexOf("<!--", idx);
-        if (cs < 0) break;
-        int ce = text.indexOf("-->", cs + 4);
-        if (ce < 0) break;
-        ce += 3;
-        if (spanMultipleLines(text, cs, ce)) {
-          regions.add(new Position(cs, ce - cs));
-        }
-        idx = ce;
-      }
-
-      // Fold multi-line CDATA
-      idx = 0;
-      while (idx < text.length()) {
-        int cs = text.indexOf("<![CDATA[", idx);
-        if (cs < 0) break;
-        int ce = text.indexOf("]]>", cs + 9);
-        if (ce < 0) break;
-        ce += 3;
-        if (spanMultipleLines(text, cs, ce)) {
-          regions.add(new Position(cs, ce - cs));
-        }
-        idx = ce;
-      }
-
-      // Fold matching open/close tags
-      Deque<String> nameStack = new ArrayDeque<>();
-      Deque<Integer> posStack = new ArrayDeque<>();
-      int i = 0;
-      while (i < text.length()) {
-        if (text.charAt(i) != '<') {
-          i++;
-          continue;
-        }
-        if (text.startsWith("<!--", i) || text.startsWith("<![CDATA[", i)) {
-          int skip =
-              text.startsWith("<!--", i) ? text.indexOf("-->", i + 4) : 
text.indexOf("]]>", i + 9);
-          i = (skip < 0) ? text.length() : skip + 3;
-          continue;
-        }
-        if (text.startsWith("<?", i) || text.startsWith("<!", i)) {
-          int gt = text.indexOf('>', i);
-          i = (gt < 0) ? text.length() : gt + 1;
-          continue;
-        }
-
-        // Close tag
-        if (text.startsWith("</", i)) {
-          int gt = text.indexOf('>', i);
-          if (gt < 0) break;
-          String closeName = text.substring(i + 2, gt).trim();
-          Deque<String> tmpNames = new ArrayDeque<>();
-          Deque<Integer> tmpPos = new ArrayDeque<>();
-          boolean found = false;
-          while (!nameStack.isEmpty()) {
-            String n = nameStack.pop();
-            int p = posStack.pop();
-            if (n.equals(closeName)) {
-              int end = gt + 1;
-              if (spanMultipleLines(text, p, end)) {
-                regions.add(new Position(p, end - p));
-              }
-              found = true;
-              break;
-            }
-            tmpNames.push(n);
-            tmpPos.push(p);
-          }
-          if (!found) {
-            while (!tmpNames.isEmpty()) {
-              nameStack.push(tmpNames.pop());
-              posStack.push(tmpPos.pop());
-            }
-          }
-          i = gt + 1;
-          continue;
-        }
-
-        // Open tag
-        int gt = text.indexOf('>', i);
-        if (gt < 0) break;
-        boolean selfClosing = text.charAt(gt - 1) == '/';
-        if (!selfClosing) {
-          int nameStart = i + 1;
-          int nameEnd = nameStart;
-          while (nameEnd < text.length()) {
-            char ch = text.charAt(nameEnd);
-            if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == 
'>' || ch == '/')
-              break;
-            nameEnd++;
-          }
-          String tagName = text.substring(nameStart, nameEnd);
-          if (!tagName.isEmpty()) {
-            nameStack.push(tagName);
-            posStack.push(i);
-          }
-        }
-        i = gt + 1;
-      }
-
-      return regions;
-    }
-
-    private static boolean spanMultipleLines(String text, int start, int end) {
-      for (int i = start; i < end && i < text.length(); i++) {
-        if (text.charAt(i) == '\n') return true;
-      }
-      return false;
-    }
-
-    /**
-     * When language is XML, typing '>' after an open tag inserts the closing 
tag and places the
-     * caret between '>' and '</tagname>'.
-     */
-    void installXmlAutoClose() {
-      if (!(sourceViewer instanceof 
org.eclipse.jface.text.ITextViewerExtension)) {
-        return;
-      }
-      ((org.eclipse.jface.text.ITextViewerExtension) sourceViewer)
-          .appendVerifyKeyListener(
-              new VerifyKeyListener() {
-                @Override
-                public void verifyKey(VerifyEvent event) {
-                  if (event.character != '>' || 
!"xml".equalsIgnoreCase(languageId)) {
-                    return;
-                  }
-                  IDocument doc = sourceViewer.getDocument();
-                  if (doc == null) return;
-                  int offset = sourceViewer.getSelectedRange().x;
-                  String before = "";
-                  try {
-                    before = offset > 0 ? doc.get(0, offset) : "";
-                  } catch (org.eclipse.jface.text.BadLocationException e) {
-                    return;
-                  }
-                  String tagName = findOpenTagNameBefore(before);
-                  if (tagName == null) return;
-                  event.doit = false;
-                  try {
-                    doc.replace(offset, 0, "></" + tagName + ">");
-                    sourceViewer.setSelectedRange(offset + 1, 0);
-                  } catch (org.eclipse.jface.text.BadLocationException e) {
-                    // ignore
-                  }
-                }
-
-                private String findOpenTagNameBefore(String text) {
-                  int i = text.length() - 1;
-                  while (i >= 0 && text.charAt(i) != '<') i--;
-                  if (i < 0) return null;
-                  if (i + 1 < text.length()) {
-                    char next = text.charAt(i + 1);
-                    if (next == '/' || next == '?' || next == '!') return null;
-                  }
-                  StringBuilder name = new StringBuilder();
-                  for (i = i + 1; i < text.length(); i++) {
-                    char ch = text.charAt(i);
-                    if (ch == ' ' || ch == '\t' || ch == '>' || ch == '/') 
break;
-                    if (Character.isLetterOrDigit(ch)
-                        || ch == ':'
-                        || ch == '-'
-                        || ch == '_'
-                        || ch == '.') {
-                      name.append(ch);
-                    } else {
-                      break;
-                    }
-                  }
-                  return name.length() > 0 ? name.toString() : null;
-                }
-              });
-    }
+    return new ContentEditorWidget(parent, languageId);
   }
 }
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
new file mode 100644
index 0000000000..4e22e4cb73
--- /dev/null
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
@@ -0,0 +1,1213 @@
+/*
+ * 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 java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.apache.hop.core.Props;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiMenuWidgets;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.eclipse.jface.text.DocumentEvent;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.IDocumentExtension3;
+import org.eclipse.jface.text.IDocumentListener;
+import org.eclipse.jface.text.ITextOperationTarget;
+import org.eclipse.jface.text.ITextViewerExtension5;
+import org.eclipse.jface.text.Position;
+import org.eclipse.jface.text.rules.FastPartitioner;
+import org.eclipse.jface.text.rules.RuleBasedPartitionScanner;
+import org.eclipse.jface.text.rules.Token;
+import org.eclipse.jface.text.source.Annotation;
+import org.eclipse.jface.text.source.AnnotationModel;
+import org.eclipse.jface.text.source.AnnotationRulerColumn;
+import org.eclipse.jface.text.source.CompositeRuler;
+import org.eclipse.jface.text.source.IAnnotationAccess;
+import org.eclipse.jface.text.source.IAnnotationAccessExtension;
+import org.eclipse.jface.text.source.LineNumberRulerColumn;
+import org.eclipse.jface.text.source.SourceViewer;
+import org.eclipse.jface.text.source.SourceViewerConfiguration;
+import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
+import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel;
+import org.eclipse.jface.text.source.projection.ProjectionViewer;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CaretListener;
+import org.eclipse.swt.custom.StyleRange;
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.custom.VerifyKeyListener;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.MouseAdapter;
+import org.eclipse.swt.events.MouseEvent;
+import org.eclipse.swt.events.PaintEvent;
+import org.eclipse.swt.events.PaintListener;
+import org.eclipse.swt.events.VerifyEvent;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.GC;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Menu;
+
+/**
+ * Desktop (RCP) implementation of the content editor using Eclipse JFace 
SourceViewer with
+ * rule-based syntax highlighting, bracket matching, and XML auto-closing tags.
+ *
+ * <p>Toolbar button registration lives in {@link ContentEditorActions} 
(shared with Hop Web).
+ * Context menu items and keyboard shortcuts stay here (RCP / JFace only).
+ */
+@GuiPlugin(name = "Content editor (desktop)")
+public class ContentEditorWidget implements IContentEditorWidget {
+  private static final Class<?> PKG = ContentEditorWidget.class;
+
+  public static final String ID_CONTEXT_MENU_UNDO = 
"ContentEditor-ContextMenu-10000-undo";
+  public static final String ID_CONTEXT_MENU_REDO = 
"ContentEditor-ContextMenu-10010-redo";
+  public static final String ID_CONTEXT_MENU_SELECT_ALL =
+      "ContentEditor-ContextMenu-20000-select-all";
+  public static final String ID_CONTEXT_MENU_UNSELECT_ALL =
+      "ContentEditor-ContextMenu-20010-unselect-all";
+  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";
+
+  private static final char[] OPEN_BRACKETS = {'(', '[', '{'};
+  private static final char[] CLOSE_BRACKETS = {')', ']', '}'};
+
+  private final Composite control;
+  private ProjectionViewer sourceViewer;
+  private final List<ModifyListener> modifyListeners = new 
CopyOnWriteArrayList<>();
+  private volatile boolean suppressModify;
+  private volatile String languageId;
+  private Control toolbar;
+  private GuiToolbarWidgets toolbarWidgets;
+  private GuiMenuWidgets contextMenuWidgets;
+  private int[] highlightedPositions;
+  private List<ProjectionAnnotation> currentFoldAnnotations = new 
ArrayList<>();
+  private Runnable pendingFoldUpdate;
+
+  ContentEditorWidget(Composite parent, String languageId) {
+
+    this.languageId = languageId != null ? languageId : "";
+
+    control = new Composite(parent, SWT.NONE);
+    control.setLayout(new FormLayout());
+    control.setLayoutData(FormDataBuilder.builder().fullSize().build());
+    PropsUi.setLook(parent);
+
+    addToolbar();
+    addSourceViewer();
+  }
+
+  private void addSourceViewer() {
+    int styles = SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL | SWT.BORDER;
+    CompositeRuler ruler = new CompositeRuler();
+    this.sourceViewer = new ProjectionViewer(control, ruler, null, false, 
styles);
+    sourceViewer.setEditable(true);
+    org.eclipse.jface.text.Document document = new 
org.eclipse.jface.text.Document();
+    RuleBasedPartitionScanner partitionScanner = new 
RuleBasedPartitionScanner();
+    partitionScanner.setDefaultReturnToken(new 
Token(IDocument.DEFAULT_CONTENT_TYPE));
+    FastPartitioner partitioner = new FastPartitioner(partitionScanner, new 
String[0]);
+    partitioner.connect(document);
+    ((IDocumentExtension3) document)
+        .setDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING, 
partitioner);
+    sourceViewer.setDocument(document, new AnnotationModel());
+
+    SourceViewerConfiguration config = 
RuleBasedSourceViewerConfiguration.create(languageId);
+    if (config != null) {
+      sourceViewer.configure(config);
+    }
+    sourceViewer.invalidateTextPresentation();
+
+    // Enable projection (folding) without ProjectionSupport so we control the 
ruler column
+    sourceViewer.doOperation(ProjectionViewer.TOGGLE);
+
+    // Line numbers first (index 0), then fold column (index 1) with explicit 
width
+    LineNumberRulerColumn lineNumberColumn = new LineNumberRulerColumn();
+    ruler.addDecorator(0, lineNumberColumn);
+
+    ProjectionAnnotationModel projModel = 
sourceViewer.getProjectionAnnotationModel();
+    if (projModel != null) {
+      FoldingAnnotationAccess foldAccess = new FoldingAnnotationAccess();
+      AnnotationRulerColumn foldColumn = new AnnotationRulerColumn(projModel, 
20, foldAccess);
+      foldColumn.addAnnotationType(ProjectionAnnotation.TYPE);
+      ruler.addDecorator(1, foldColumn);
+
+      Control foldControl = foldColumn.getControl();
+      if (foldControl != null) {
+        foldControl.addMouseListener(
+            new MouseAdapter() {
+              @Override
+              public void mouseUp(MouseEvent e) {
+                int line = foldColumn.toDocumentLineNumber(e.y);
+                if (line < 0) return;
+                IDocument doc = sourceViewer.getDocument();
+                if (doc == null) return;
+                java.util.Iterator<Annotation> it = 
projModel.getAnnotationIterator();
+                while (it.hasNext()) {
+                  Annotation ann = it.next();
+                  if (ann instanceof ProjectionAnnotation) {
+                    Position pos = projModel.getPosition(ann);
+                    if (pos != null) {
+                      try {
+                        int annLine = doc.getLineOfOffset(pos.getOffset());
+                        if (annLine == line) {
+                          
projModel.toggleExpansionState((ProjectionAnnotation) ann);
+                          return;
+                        }
+                      } catch (org.eclipse.jface.text.BadLocationException 
ignored) {
+                        // ignore
+                      }
+                    }
+                  }
+                }
+              }
+            });
+      }
+    }
+
+    applyFontFromHop(sourceViewer);
+    org.eclipse.swt.graphics.Font editorFont = 
sourceViewer.getTextWidget().getFont();
+    if (editorFont != null && !editorFont.isDisposed()) {
+      lineNumberColumn.setFont(editorFont);
+    }
+    boolean dark = PropsUi.getInstance().isDarkMode();
+    if (dark) {
+      org.eclipse.swt.graphics.Color lineNumFg =
+          new org.eclipse.swt.graphics.Color(control.getDisplay(), 130, 130, 
130);
+      org.eclipse.swt.graphics.Color lineNumBg =
+          new org.eclipse.swt.graphics.Color(control.getDisplay(), 40, 40, 40);
+      lineNumberColumn.setForeground(lineNumFg);
+      lineNumberColumn.setBackground(lineNumBg);
+    } else {
+      org.eclipse.swt.graphics.Color lineNumFg =
+          new org.eclipse.swt.graphics.Color(control.getDisplay(), 120, 120, 
120);
+      lineNumberColumn.setForeground(lineNumFg);
+    }
+    sourceViewer.getTextWidget().setTabs(4);
+    sourceViewer
+        .getDocument()
+        .addDocumentListener(
+            new IDocumentListener() {
+              @Override
+              public void documentAboutToBeChanged(DocumentEvent event) {}
+
+              @Override
+              public void documentChanged(DocumentEvent event) {
+                fireModify();
+              }
+
+              private void fireModify() {
+                if (suppressModify) {
+                  return;
+                }
+                Display display = control.getDisplay();
+                if (display == null || control.isDisposed()) {
+                  return;
+                }
+                display.asyncExec(
+                    () -> {
+                      if (control.isDisposed() || suppressModify) {
+                        return;
+                      }
+
+                      updateGui();
+
+                      for (ModifyListener listener : new 
ArrayList<>(modifyListeners)) {
+                        try {
+                          listener.modifyText(null);
+                        } catch (Exception ignored) {
+                          // ignore
+                        }
+                      }
+                    });
+              }
+            });
+
+    sourceViewer.addSelectionChangedListener(
+        event -> {
+          if (suppressModify) {
+            return;
+          }
+          updateGui();
+        });
+
+    sourceViewer
+        .getControl()
+        
.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
+
+    // Installs bracket highlighting and XML auto-closing when language is xml.
+    installBracketHighlighting();
+    installFolding();
+    installBlockGuidePainter();
+    installCollapsePlaceholderPainter();
+    installXmlAutoClose();
+    installMenu();
+
+    updateGui();
+  }
+
+  public void updateGui() {
+    boolean canUndo = sourceViewer.canDoOperation(ITextOperationTarget.UNDO);
+    boolean canRedo = sourceViewer.canDoOperation(ITextOperationTarget.REDO);
+    boolean canCut = sourceViewer.canDoOperation(ITextOperationTarget.CUT);
+    boolean canCopy = sourceViewer.canDoOperation(ITextOperationTarget.COPY);
+    boolean canPaste = sourceViewer.canDoOperation(ITextOperationTarget.PASTE);
+
+    // Update the toolbar items...
+    if (toolbarWidgets != null) {
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_UNDO, 
canUndo);
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_REDO, 
canRedo);
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_CUT, 
canCut);
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_COPY, 
canCopy);
+      toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_PASTE, 
canPaste);
+    }
+
+    // Update the HopGui main menu items...
+    GuiMenuWidgets mainMenuWidgets = HopGui.getInstance().getMainMenuWidgets();
+    mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_UNDO, canUndo);
+    mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_REDO, canRedo);
+    mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_CUT, canCut);
+    mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_COPY, canCopy);
+    mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_PASTE, canPaste);
+  }
+
+  private void addToolbar() {
+    // Create the toolbar container using the facade
+    IToolbarContainer toolbarContainer =
+        ToolbarFacade.createToolbarContainer(control, SWT.WRAP | SWT.RIGHT | 
SWT.HORIZONTAL);
+    toolbar = toolbarContainer.getControl();
+    toolbar.setLayoutData(FormDataBuilder.builder().top().fullWidth().build());
+    PropsUi.setLook(toolbar, Props.WIDGET_STYLE_TOOLBAR);
+
+    // Create the toolbar widgets using GuiToolbarWidgets
+    toolbarWidgets = new GuiToolbarWidgets();
+    toolbarWidgets.registerGuiPluginObject(this);
+    toolbarWidgets.createToolbarWidgets(toolbarContainer, 
GUI_PLUGIN_TOOLBAR_PARENT_ID);
+    toolbar.pack();
+  }
+
+  @Override
+  public Control getControl() {
+    return control;
+  }
+
+  @Override
+  public String getText() {
+    IDocument doc = sourceViewer.getDocument();
+    return doc != null ? doc.get() : "";
+  }
+
+  @Override
+  public void setText(String text) {
+    doSetText(text != null ? text : "", false);
+  }
+
+  @Override
+  public void setTextSuppressModify(String text) {
+    doSetText(text != null ? text : "", true);
+  }
+
+  private void doSetText(String text, boolean suppress) {
+    if (suppress) {
+      suppressModify = true;
+    }
+    try {
+      IDocument doc = sourceViewer.getDocument();
+      if (doc != null) {
+        doc.set(text);
+      }
+      sourceViewer.setSelectedRange(0, 0);
+      sourceViewer.invalidateTextPresentation();
+    } finally {
+      if (suppress) {
+        suppressModify = false;
+      }
+    }
+  }
+
+  @Override
+  public String getLanguage() {
+    return languageId;
+  }
+
+  @Override
+  public void setLanguage(String languageId) {
+    this.languageId = languageId != null ? languageId : "";
+    SourceViewerConfiguration config = 
RuleBasedSourceViewerConfiguration.create(languageId);
+    if (config != null) {
+      sourceViewer.unconfigure();
+      sourceViewer.configure(config);
+      sourceViewer.invalidateTextPresentation();
+    }
+    updateFoldingRegions();
+  }
+
+  @Override
+  public void setReadOnly(boolean readOnly) {
+    sourceViewer.setEditable(!readOnly);
+  }
+
+  @Override
+  public void addModifyListener(ModifyListener listener) {
+    if (listener != null) {
+      modifyListeners.add(listener);
+    }
+  }
+
+  @Override
+  public void removeModifyListener(ModifyListener listener) {
+    if (listener != null) {
+      modifyListeners.remove(listener);
+    }
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_UNDO,
+      label = "i18n::ContentEditorWidget.Menu.Undo",
+      image = "ui/images/undo.svg")
+  @GuiKeyboardShortcut(control = true, key = 'z')
+  @GuiOsxKeyboardShortcut(command = true, key = 'z')
+  @Override
+  public void undo() {
+    sourceViewer.doOperation(SourceViewer.UNDO);
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_REDO,
+      label = "i18n::ContentEditorWidget.Menu.Redo",
+      image = "ui/images/redo.svg")
+  @GuiKeyboardShortcut(control = true, shift = true, key = 'z')
+  @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'z')
+  @Override
+  public void redo() {
+    sourceViewer.doOperation(SourceViewer.REDO);
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_SELECT_ALL,
+      label = "i18n::ContentEditorWidget.Menu.SelectAll",
+      image = "ui/images/select-all.svg",
+      separator = true)
+  @GuiKeyboardShortcut(control = true, key = 'a')
+  @GuiOsxKeyboardShortcut(command = true, key = 'a')
+  @Override
+  public void selectAll() {
+    IDocument doc = sourceViewer.getDocument();
+    if (doc != null) {
+      sourceViewer.setSelectedRange(0, doc.getLength());
+    }
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_UNSELECT_ALL,
+      label = "i18n::ContentEditorWidget.Menu.UnselectAll",
+      image = "ui/images/unselect-all.svg")
+  @GuiKeyboardShortcut(key = SWT.ESC)
+  @GuiOsxKeyboardShortcut(key = SWT.ESC)
+  @Override
+  public void unselectAll() {
+    sourceViewer.setSelectedRange(0, 0);
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_COPY,
+      label = "i18n::ContentEditorWidget.Menu.Copy",
+      image = "ui/images/copy.svg",
+      separator = true)
+  @GuiKeyboardShortcut(control = true, key = 'c')
+  @GuiOsxKeyboardShortcut(command = true, key = 'c')
+  @Override
+  public void copy() {
+    sourceViewer.doOperation(SourceViewer.COPY);
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_CUT,
+      label = "i18n::ContentEditorWidget.Menu.Cut",
+      image = "ui/images/cut.svg")
+  @GuiKeyboardShortcut(control = true, key = 'x')
+  @GuiOsxKeyboardShortcut(command = true, key = 'x')
+  @Override
+  public void cut() {
+    sourceViewer.doOperation(SourceViewer.CUT);
+  }
+
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = ID_CONTEXT_MENU_PASTE,
+      label = "i18n::ContentEditorWidget.Menu.Paste",
+      image = "ui/images/paste.svg")
+  @GuiKeyboardShortcut(control = true, key = 'v')
+  @GuiOsxKeyboardShortcut(command = true, key = 'v')
+  @Override
+  public void paste() {
+    sourceViewer.doOperation(SourceViewer.PASTE);
+  }
+
+  private static void applyFontFromHop(SourceViewer sourceViewer) {
+    try {
+      org.eclipse.swt.graphics.Font swtFont = 
GuiResource.getInstance().getFontFixed();
+      if (swtFont != null && !swtFont.isDisposed()) {
+        sourceViewer.getTextWidget().setFont(swtFont);
+      }
+    } catch (Exception e) {
+      // GuiResource or font not available
+    }
+  }
+
+  void installBracketHighlighting() {
+    StyledText st = sourceViewer.getTextWidget();
+    Color hlBg =
+        new Color(
+            st.getDisplay(),
+            PropsUi.getInstance().isDarkMode()
+                ? new org.eclipse.swt.graphics.RGB(80, 80, 80)
+                : new org.eclipse.swt.graphics.RGB(220, 220, 220));
+    st.addDisposeListener(e -> hlBg.dispose());
+
+    CaretListener caretListener =
+        event -> {
+          clearBracketHighlight(st);
+          String text = st.getText();
+          int offset = event.caretOffset;
+          int[] enclosing = findEnclosingBrackets(text, offset);
+          if (enclosing != null) {
+            applyBracketHighlight(st, enclosing[0], enclosing[1], hlBg);
+          }
+        };
+    st.addCaretListener(caretListener);
+  }
+
+  private void clearBracketHighlight(StyledText st) {
+    if (highlightedPositions == null) return;
+    for (int pos : highlightedPositions) {
+      if (pos < 0 || pos >= st.getCharCount()) continue;
+      StyleRange existing = st.getStyleRangeAtOffset(pos);
+      if (existing != null) {
+        existing.background = null;
+        st.setStyleRange(existing);
+      }
+    }
+    highlightedPositions = null;
+  }
+
+  private void applyBracketHighlight(StyledText st, int pos1, int pos2, Color 
bg) {
+    highlightedPositions = new int[] {pos1, pos2};
+    for (int pos : highlightedPositions) {
+      if (pos < 0 || pos >= st.getCharCount()) continue;
+      StyleRange sr = st.getStyleRangeAtOffset(pos);
+      if (sr == null) {
+        sr = new StyleRange();
+        sr.start = pos;
+        sr.length = 1;
+      } else {
+        sr = (StyleRange) sr.clone();
+      }
+      sr.background = bg;
+      st.setStyleRange(sr);
+    }
+  }
+
+  /**
+   * Finds the innermost enclosing bracket pair around the given offset. Scans 
forward from the
+   * start of the text, tracking a bracket stack. The top of the stack at the 
cursor position is the
+   * enclosing open bracket; its forward match gives the close bracket.
+   *
+   * @return int[]{openPos, closePos} or null if not inside any brackets
+   */
+  private static int[] findEnclosingBrackets(String text, int offset) {
+    Deque<int[]> stack = new ArrayDeque<>();
+    boolean inStr = false;
+
+    for (int i = 0; i < offset && i < text.length(); i++) {
+      char c = text.charAt(i);
+      if (inStr) {
+        if (c == '\\' && i + 1 < text.length()) {
+          i++;
+          continue;
+        }
+        if (c == '"') inStr = false;
+        continue;
+      }
+      if (c == '"') {
+        inStr = true;
+        continue;
+      }
+
+      int openIdx = indexOf(OPEN_BRACKETS, c);
+      if (openIdx >= 0) {
+        stack.push(new int[] {i, openIdx});
+      } else {
+        int closeIdx = indexOf(CLOSE_BRACKETS, c);
+        if (closeIdx >= 0 && !stack.isEmpty() && stack.peek()[1] == closeIdx) {
+          stack.pop();
+        }
+      }
+    }
+
+    // Walk the stack from innermost (top) outward, find one whose close 
bracket is past offset
+    while (!stack.isEmpty()) {
+      int[] top = stack.pop();
+      int openPos = top[0];
+      int closePos = findMatchForward(text, openPos, OPEN_BRACKETS[top[1]], 
CLOSE_BRACKETS[top[1]]);
+      if (closePos >= offset) {
+        return new int[] {openPos, closePos};
+      }
+    }
+    return null;
+  }
+
+  /** Finds the matching close bracket for an open bracket at pos, scanning 
forward. */
+  private static int findMatchForward(String text, int pos, char open, char 
close) {
+    int depth = 0;
+    boolean inStr = false;
+    for (int i = pos; i < text.length(); i++) {
+      char c = text.charAt(i);
+      if (inStr) {
+        if (c == '\\' && i + 1 < text.length()) {
+          i++;
+          continue;
+        }
+        if (c == '"') inStr = false;
+        continue;
+      }
+      if (c == '"') {
+        inStr = true;
+        continue;
+      }
+      if (c == open) depth++;
+      else if (c == close) depth--;
+      if (depth == 0) return i;
+    }
+    return -1;
+  }
+
+  private static int indexOf(char[] arr, char c) {
+    for (int i = 0; i < arr.length; i++) {
+      if (arr[i] == c) return i;
+    }
+    return -1;
+  }
+
+  void installMenu() {
+    StyledText styledText = sourceViewer.getTextWidget();
+
+    Menu contextMenu = new Menu(styledText);
+    contextMenuWidgets = new GuiMenuWidgets();
+    contextMenuWidgets.registerGuiPluginObject(this);
+    contextMenuWidgets.createMenuWidgets(
+        GUI_PLUGIN_CONTEXT_MENU_PARENT_ID, styledText.getShell(), contextMenu);
+    styledText.setMenu(contextMenu);
+    styledText.addListener(
+        SWT.MenuDetect,
+        event -> {
+          // Update the context menu items...
+          contextMenuWidgets.enableMenuItem(
+              ID_CONTEXT_MENU_UNDO, 
sourceViewer.canDoOperation(ITextOperationTarget.UNDO));
+          contextMenuWidgets.enableMenuItem(
+              ID_CONTEXT_MENU_REDO, 
sourceViewer.canDoOperation(ITextOperationTarget.REDO));
+          contextMenuWidgets.enableMenuItem(
+              ID_CONTEXT_MENU_CUT, 
sourceViewer.canDoOperation(ITextOperationTarget.CUT));
+          contextMenuWidgets.enableMenuItem(
+              ID_CONTEXT_MENU_COPY, 
sourceViewer.canDoOperation(ITextOperationTarget.COPY));
+          contextMenuWidgets.enableMenuItem(
+              ID_CONTEXT_MENU_PASTE, 
sourceViewer.canDoOperation(ITextOperationTarget.PASTE));
+        });
+  }
+
+  void installFolding() {
+    sourceViewer
+        .getDocument()
+        .addDocumentListener(
+            new IDocumentListener() {
+              @Override
+              public void documentAboutToBeChanged(DocumentEvent event) {}
+
+              @Override
+              public void documentChanged(DocumentEvent event) {
+                scheduleFoldUpdate();
+              }
+            });
+  }
+
+  /** Paints vertical block guide lines (indent/bracket scope) like VS 
Code/IntelliJ. */
+  void installBlockGuidePainter() {
+    StyledText st = sourceViewer.getTextWidget();
+    boolean dark = PropsUi.getInstance().isDarkMode();
+    Color guideColor =
+        new Color(st.getDisplay(), dark ? 60 : 200, dark ? 60 : 200, dark ? 60 
: 200);
+    st.addDisposeListener(e -> guideColor.dispose());
+
+    final int guideWidthPx = 10;
+
+    st.addPaintListener(
+        new PaintListener() {
+          @Override
+          public void paintControl(PaintEvent e) {
+            if (!"json".equalsIgnoreCase(languageId) && 
!"xml".equalsIgnoreCase(languageId)) {
+              return;
+            }
+            String text = getText();
+            if (text.isEmpty()) return;
+
+            List<Position> regions =
+                "json".equalsIgnoreCase(languageId)
+                    ? computeJsonFoldRegions(text)
+                    : computeXmlFoldRegions(text);
+            if (regions.isEmpty()) return;
+
+            // Convert to (startLine+1, closingLine) blocks — guides show 
between open/close,
+            // not on the opening or closing line itself.
+            // Column = nesting depth (how many other blocks contain this one).
+            List<int[]> rawBlocks = new ArrayList<>();
+            for (Position pos : regions) {
+              int startLine = lineOfOffset(text, pos.offset);
+              int closingLine = lineOfOffset(text, pos.offset + pos.length - 
1);
+              if (closingLine > startLine + 1) {
+                rawBlocks.add(new int[] {startLine + 1, closingLine});
+              }
+            }
+            // Deduplicate identical ranges
+            Map<Long, int[]> uniqueBlocks = new HashMap<>();
+            for (int[] b : rawBlocks) {
+              long key = ((long) b[0] << 32) | (b[1] & 0xFFFFFFFFL);
+              uniqueBlocks.putIfAbsent(key, b);
+            }
+            // Compute depth for each block: count how many other blocks 
strictly contain it
+            List<int[]> blocks = new ArrayList<>();
+            for (int[] b : uniqueBlocks.values()) {
+              int depth = 0;
+              for (int[] other : uniqueBlocks.values()) {
+                if (other[0] <= b[0] && other[1] >= b[1] && other != b) {
+                  depth++;
+                }
+              }
+              blocks.add(new int[] {b[0], b[1], depth});
+            }
+
+            int lineHeight = st.getLineHeight();
+            int topIndex = st.getTopIndex();
+            int visibleCount =
+                Math.min(
+                    st.getLineCount() - topIndex,
+                    (st.getClientArea().height + lineHeight - 1) / lineHeight);
+
+            e.gc.setForeground(guideColor);
+            e.gc.setLineWidth(1);
+            e.gc.setLineStyle(SWT.LINE_SOLID);
+
+            for (int i = 0; i < visibleCount; i++) {
+              int widgetLine = topIndex + i;
+              int modelLine = sourceViewer.widgetLine2ModelLine(widgetLine);
+              if (modelLine < 0) continue;
+              int y = i * lineHeight;
+              for (int[] block : blocks) {
+                int start = block[0];
+                int endExcl = block[1];
+                int col = block[2];
+                if (modelLine >= start && modelLine < endExcl) {
+                  int x = 2 + col * guideWidthPx;
+                  e.gc.drawLine(x, y, x, y + lineHeight);
+                }
+              }
+            }
+          }
+        });
+  }
+
+  /** 0-based line number for the given offset (counts '\n' before offset). */
+  private static int lineOfOffset(String text, int offset) {
+    int line = 0;
+    for (int i = 0; i < offset && i < text.length(); i++) {
+      if (text.charAt(i) == '\n') line++;
+    }
+    return line;
+  }
+
+  /** Paints "..." at the end of the first line of each collapsed fold (like 
VS Code). */
+  void installCollapsePlaceholderPainter() {
+    StyledText st = sourceViewer.getTextWidget();
+    boolean dark = PropsUi.getInstance().isDarkMode();
+    Color placeholderColor =
+        new Color(st.getDisplay(), dark ? 140 : 120, dark ? 140 : 120, dark ? 
140 : 120);
+    st.addDisposeListener(e -> placeholderColor.dispose());
+
+    st.addPaintListener(
+        e1 -> {
+          ProjectionViewer pv = (ProjectionViewer) sourceViewer;
+          ProjectionAnnotationModel model = pv.getProjectionAnnotationModel();
+          if (model == null) return;
+          IDocument doc = sourceViewer.getDocument();
+          if (doc == null) return;
+          ITextViewerExtension5 ext5 = (ITextViewerExtension5) sourceViewer;
+
+          e1.gc.setForeground(placeholderColor);
+          e1.gc.setFont(st.getFont());
+
+          java.util.Iterator<Annotation> it = model.getAnnotationIterator();
+          while (it.hasNext()) {
+            Annotation ann = it.next();
+            if (!(ann instanceof ProjectionAnnotation)) continue;
+            if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
+            Position pos = model.getPosition(ann);
+            if (pos == null) continue;
+            int modelLine;
+            try {
+              modelLine = doc.getLineOfOffset(pos.offset);
+            } catch (org.eclipse.jface.text.BadLocationException ex) {
+              continue;
+            }
+            int widgetLine = ext5.modelLine2WidgetLine(modelLine);
+            if (widgetLine < 0 || widgetLine >= st.getLineCount()) continue;
+            int lineEndOffset;
+            try {
+              int lineStart = st.getOffsetAtLine(widgetLine);
+              int lineLen = st.getLine(widgetLine).length();
+              lineEndOffset = lineStart + lineLen;
+            } catch (Exception ex) {
+              continue;
+            }
+            org.eclipse.swt.graphics.Point pt = 
st.getLocationAtOffset(lineEndOffset);
+            if (pt != null) {
+              e1.gc.drawText("...", pt.x + 4, pt.y);
+            }
+          }
+        });
+
+    // Click on "..." placeholder expands the collapsed fold
+    final int placeholderClickWidth = 24;
+    st.addMouseListener(
+        new org.eclipse.swt.events.MouseAdapter() {
+          @Override
+          public void mouseUp(org.eclipse.swt.events.MouseEvent e) {
+            if (e.button != 1) return;
+            ProjectionViewer pv = (ProjectionViewer) sourceViewer;
+            ProjectionAnnotationModel model = 
pv.getProjectionAnnotationModel();
+            if (model == null) return;
+            IDocument doc = sourceViewer.getDocument();
+            if (doc == null) return;
+            ITextViewerExtension5 ext5 = (ITextViewerExtension5) sourceViewer;
+
+            int lineHeight = st.getLineHeight();
+            java.util.Iterator<Annotation> it = model.getAnnotationIterator();
+            while (it.hasNext()) {
+              Annotation ann = it.next();
+              if (!(ann instanceof ProjectionAnnotation)) continue;
+              if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
+              Position pos = model.getPosition(ann);
+              if (pos == null) continue;
+              int modelLine;
+              try {
+                modelLine = doc.getLineOfOffset(pos.offset);
+              } catch (org.eclipse.jface.text.BadLocationException ex) {
+                continue;
+              }
+              int widgetLine = ext5.modelLine2WidgetLine(modelLine);
+              if (widgetLine < 0 || widgetLine >= st.getLineCount()) continue;
+              int lineEndOffset;
+              try {
+                int lineStart = st.getOffsetAtLine(widgetLine);
+                int lineLen = st.getLine(widgetLine).length();
+                lineEndOffset = lineStart + lineLen;
+              } catch (Exception ex) {
+                continue;
+              }
+              org.eclipse.swt.graphics.Point pt = 
st.getLocationAtOffset(lineEndOffset);
+              if (pt == null) continue;
+              int x1 = pt.x + 4;
+              int y1 = pt.y;
+              if (e.x >= x1
+                  && e.x <= x1 + placeholderClickWidth
+                  && e.y >= y1
+                  && e.y < y1 + lineHeight) {
+                model.expand(ann);
+                return;
+              }
+            }
+          }
+        });
+  }
+
+  private void scheduleFoldUpdate() {
+    Display display = control.getDisplay();
+    if (display == null || display.isDisposed()) return;
+    if (pendingFoldUpdate != null) {
+      display.timerExec(-1, pendingFoldUpdate);
+    }
+    pendingFoldUpdate =
+        () -> {
+          if (!control.isDisposed()) {
+            updateFoldingRegions();
+          }
+          pendingFoldUpdate = null;
+        };
+    display.timerExec(500, pendingFoldUpdate);
+  }
+
+  private void updateFoldingRegions() {
+    ProjectionAnnotationModel model = 
sourceViewer.getProjectionAnnotationModel();
+    if (model == null) return;
+
+    if (pendingFoldUpdate != null && !control.isDisposed()) {
+      control.getDisplay().timerExec(-1, pendingFoldUpdate);
+      pendingFoldUpdate = null;
+    }
+
+    String text = getText();
+    List<Position> regions;
+    if ("json".equalsIgnoreCase(languageId)) {
+      regions = computeJsonFoldRegions(text);
+    } else if ("xml".equalsIgnoreCase(languageId)) {
+      regions = computeXmlFoldRegions(text);
+    } else {
+      regions = List.of();
+    }
+
+    Annotation[] deletions = currentFoldAnnotations.toArray(new Annotation[0]);
+    Map<ProjectionAnnotation, Position> additions = new HashMap<>();
+    List<ProjectionAnnotation> newAnnotations = new ArrayList<>();
+    for (Position pos : regions) {
+      ProjectionAnnotation annotation = new ProjectionAnnotation();
+      additions.put(annotation, pos);
+      newAnnotations.add(annotation);
+    }
+
+    model.modifyAnnotations(deletions, additions, null);
+    currentFoldAnnotations = newAnnotations;
+  }
+
+  private static List<Position> computeJsonFoldRegions(String text) {
+    List<Position> regions = new ArrayList<>();
+    Deque<Integer> stack = new ArrayDeque<>();
+    boolean inString = false;
+
+    for (int i = 0; i < text.length(); i++) {
+      char c = text.charAt(i);
+      if (inString) {
+        if (c == '\\' && i + 1 < text.length()) {
+          i++;
+          continue;
+        }
+        if (c == '"') inString = false;
+        continue;
+      }
+      if (c == '"') {
+        inString = true;
+        continue;
+      }
+      if (c == '{' || c == '[') {
+        stack.push(i);
+      } else if (c == '}' || c == ']') {
+        if (!stack.isEmpty()) {
+          int start = stack.pop();
+          if (spanMultipleLines(text, start, i)) {
+            regions.add(new Position(start, i - start + 1));
+          }
+        }
+      }
+    }
+    return regions;
+  }
+
+  private static List<Position> computeXmlFoldRegions(String text) {
+    List<Position> regions = new ArrayList<>();
+
+    // Fold multi-line comments
+    int idx = 0;
+    while (idx < text.length()) {
+      int cs = text.indexOf("<!--", idx);
+      if (cs < 0) break;
+      int ce = text.indexOf("-->", cs + 4);
+      if (ce < 0) break;
+      ce += 3;
+      if (spanMultipleLines(text, cs, ce)) {
+        regions.add(new Position(cs, ce - cs));
+      }
+      idx = ce;
+    }
+
+    // Fold multi-line CDATA
+    idx = 0;
+    while (idx < text.length()) {
+      int cs = text.indexOf("<![CDATA[", idx);
+      if (cs < 0) break;
+      int ce = text.indexOf("]]>", cs + 9);
+      if (ce < 0) break;
+      ce += 3;
+      if (spanMultipleLines(text, cs, ce)) {
+        regions.add(new Position(cs, ce - cs));
+      }
+      idx = ce;
+    }
+
+    // Fold matching open/close tags
+    Deque<String> nameStack = new ArrayDeque<>();
+    Deque<Integer> posStack = new ArrayDeque<>();
+    int i = 0;
+    while (i < text.length()) {
+      if (text.charAt(i) != '<') {
+        i++;
+        continue;
+      }
+      if (text.startsWith("<!--", i) || text.startsWith("<![CDATA[", i)) {
+        int skip =
+            text.startsWith("<!--", i) ? text.indexOf("-->", i + 4) : 
text.indexOf("]]>", i + 9);
+        i = (skip < 0) ? text.length() : skip + 3;
+        continue;
+      }
+      if (text.startsWith("<?", i) || text.startsWith("<!", i)) {
+        int gt = text.indexOf('>', i);
+        i = (gt < 0) ? text.length() : gt + 1;
+        continue;
+      }
+
+      // Close tag
+      if (text.startsWith("</", i)) {
+        int gt = text.indexOf('>', i);
+        if (gt < 0) break;
+        String closeName = text.substring(i + 2, gt).trim();
+        Deque<String> tmpNames = new ArrayDeque<>();
+        Deque<Integer> tmpPos = new ArrayDeque<>();
+        boolean found = false;
+        while (!nameStack.isEmpty()) {
+          String n = nameStack.pop();
+          int p = posStack.pop();
+          if (n.equals(closeName)) {
+            int end = gt + 1;
+            if (spanMultipleLines(text, p, end)) {
+              regions.add(new Position(p, end - p));
+            }
+            found = true;
+            break;
+          }
+          tmpNames.push(n);
+          tmpPos.push(p);
+        }
+        if (!found) {
+          while (!tmpNames.isEmpty()) {
+            nameStack.push(tmpNames.pop());
+            posStack.push(tmpPos.pop());
+          }
+        }
+        i = gt + 1;
+        continue;
+      }
+
+      // Open tag
+      int gt = text.indexOf('>', i);
+      if (gt < 0) break;
+      boolean selfClosing = text.charAt(gt - 1) == '/';
+      if (!selfClosing) {
+        int nameStart = i + 1;
+        int nameEnd = nameStart;
+        while (nameEnd < text.length()) {
+          char ch = text.charAt(nameEnd);
+          if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '>' 
|| ch == '/') break;
+          nameEnd++;
+        }
+        String tagName = text.substring(nameStart, nameEnd);
+        if (!tagName.isEmpty()) {
+          nameStack.push(tagName);
+          posStack.push(i);
+        }
+      }
+      i = gt + 1;
+    }
+
+    return regions;
+  }
+
+  private static boolean spanMultipleLines(String text, int start, int end) {
+    for (int i = start; i < end && i < text.length(); i++) {
+      if (text.charAt(i) == '\n') return true;
+    }
+    return false;
+  }
+
+  /**
+   * When language is XML, typing '>' after an open tag inserts the closing 
tag and places the caret
+   * between '>' and '</tagname>'.
+   */
+  void installXmlAutoClose() {
+    sourceViewer.appendVerifyKeyListener(
+        new VerifyKeyListener() {
+          @Override
+          public void verifyKey(VerifyEvent event) {
+            if (event.character != '>' || !"xml".equalsIgnoreCase(languageId)) 
{
+              return;
+            }
+            IDocument doc = sourceViewer.getDocument();
+            if (doc == null) return;
+            int offset = sourceViewer.getSelectedRange().x;
+            String before = "";
+            try {
+              before = offset > 0 ? doc.get(0, offset) : "";
+            } catch (org.eclipse.jface.text.BadLocationException e) {
+              return;
+            }
+            String tagName = findOpenTagNameBefore(before);
+            if (tagName == null) return;
+            event.doit = false;
+            try {
+              doc.replace(offset, 0, "></" + tagName + ">");
+              sourceViewer.setSelectedRange(offset + 1, 0);
+            } catch (org.eclipse.jface.text.BadLocationException e) {
+              // ignore
+            }
+          }
+
+          private String findOpenTagNameBefore(String text) {
+            int i = text.length() - 1;
+            while (i >= 0 && text.charAt(i) != '<') i--;
+            if (i < 0) return null;
+            if (i + 1 < text.length()) {
+              char next = text.charAt(i + 1);
+              if (next == '/' || next == '?' || next == '!') return null;
+            }
+            StringBuilder name = new StringBuilder();
+            for (i = i + 1; i < text.length(); i++) {
+              char ch = text.charAt(i);
+              if (ch == ' ' || ch == '\t' || ch == '>' || ch == '/') break;
+              if (Character.isLetterOrDigit(ch)
+                  || ch == ':'
+                  || ch == '-'
+                  || ch == '_'
+                  || ch == '.') {
+                name.append(ch);
+              } else {
+                break;
+              }
+            }
+            return name.isEmpty() ? null : name.toString();
+          }
+        });
+  }
+
+  /**
+   * Annotation access for the fold ruler column: paints chevron icons and 
reports annotation
+   * metadata to the {@link AnnotationRulerColumn}.
+   */
+  private static class FoldingAnnotationAccess
+      implements IAnnotationAccess, IAnnotationAccessExtension {
+
+    @Override
+    public Object getType(Annotation annotation) {
+      return annotation.getType();
+    }
+
+    @Override
+    public boolean isMultiLine(Annotation annotation) {
+      return true;
+    }
+
+    @Override
+    public boolean isTemporary(Annotation annotation) {
+      return true;
+    }
+
+    @Override
+    public String getTypeLabel(Annotation annotation) {
+      return annotation.getText() != null ? annotation.getText() : "";
+    }
+
+    @Override
+    public int getLayer(Annotation annotation) {
+      return 0;
+    }
+
+    @Override
+    public Object[] getSupertypes(Object annotationType) {
+      return new Object[0];
+    }
+
+    @Override
+    public boolean isSubtype(Object annotationType, Object potentialSupertype) 
{
+      return annotationType != null && 
annotationType.equals(potentialSupertype);
+    }
+
+    @Override
+    public boolean isPaintable(Annotation annotation) {
+      return annotation instanceof ProjectionAnnotation;
+    }
+
+    @Override
+    public void paint(Annotation annotation, GC gc, Canvas canvas, Rectangle 
rectangle) {
+      if (!(annotation instanceof ProjectionAnnotation pa)) return;
+
+      boolean collapsed = pa.isCollapsed();
+      int x = rectangle.x;
+      int y = rectangle.y;
+      int w = rectangle.width;
+      int lineHeight = 14;
+      int h = Math.min(rectangle.height, lineHeight);
+      if (w < 5 || h < 5) return;
+
+      
gc.setForeground(canvas.getDisplay().getSystemColor(SWT.COLOR_WIDGET_FOREGROUND));
+      gc.setAntialias(SWT.ON);
+      gc.setLineWidth(1);
+      int pad = 2;
+      int cx = x + w / 2;
+      int cy = y + h / 2;
+      if (collapsed) {
+        // Right-pointing chevron: >
+        int left = x + pad;
+        int right = x + w - pad;
+        int top = y + pad;
+        int bottom = y + h - pad;
+        gc.drawLine(left, top, right, cy);
+        gc.drawLine(right, cy, left, bottom);
+      } else {
+        // Down-pointing chevron: v
+        int left = x + pad;
+        int right = x + w - pad;
+        int top = y + pad;
+        int bottom = y + h - pad;
+        gc.drawLine(left, top, cx, bottom);
+        gc.drawLine(cx, bottom, right, top);
+      }
+    }
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
index b029792d3c..8ca4cc7785 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.core.gui;
 
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.util.List;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.plugin.GuiRegistry;
@@ -193,21 +194,28 @@ public class BaseGuiWidgets {
     return e -> {
       try {
         // See if we can find a static method which accepts this instance as 
an argument.
-        // What's the registered GUI object we have?
+        // Parameter type may be the concrete class or any 
interface/superclass (e.g. facade
+        // toolbars register a ContentEditorWidget but listeners take 
IContentEditorWidget).
         //
-        try {
-          Class<?> listenerClass = classLoader.loadClass(listenerClassName);
-          Method listenerMethod =
-              listenerClass.getMethod(listenerMethodName, 
guiPluginObject.getClass());
-          listenerMethod.invoke(null, guiPluginObject);
-          return;
-        } catch (NoSuchMethodException
-            | ClassNotFoundException
-            | InvocationTargetException exception) {
-          // Ignore this and re-try with the standard empty method
-        } catch (Exception exception) {
-          // An exception thrown by the method itself
-          throw exception;
+        if (guiPluginObject != null) {
+          try {
+            Class<?> listenerClass = classLoader.loadClass(listenerClassName);
+            Method listenerMethod =
+                findStaticListenerMethod(
+                    listenerClass, listenerMethodName, 
guiPluginObject.getClass());
+            if (listenerMethod != null) {
+              listenerMethod.invoke(null, guiPluginObject);
+              return;
+            }
+          } catch (ClassNotFoundException exception) {
+            // No such listener class — fall through to the instance method 
path
+          } catch (InvocationTargetException exception) {
+            // Static method was found and threw; do not hide the error behind 
a retry
+            throw exception;
+          } catch (Exception exception) {
+            // An exception thrown by the method itself
+            throw exception;
+          }
         }
 
         Object guiPluginInstance =
@@ -237,6 +245,30 @@ public class BaseGuiWidgets {
     };
   }
 
+  /**
+   * Find a public static method with a single parameter assignable from 
{@code argumentType}.
+   * Prefers the most specific matching parameter type when more than one 
candidate exists.
+   */
+  static Method findStaticListenerMethod(
+      Class<?> listenerClass, String methodName, Class<?> argumentType) {
+    Method best = null;
+    for (Method method : listenerClass.getMethods()) {
+      if (!methodName.equals(method.getName())
+          || !Modifier.isStatic(method.getModifiers())
+          || method.getParameterCount() != 1) {
+        continue;
+      }
+      Class<?> parameterType = method.getParameterTypes()[0];
+      if (!parameterType.isAssignableFrom(argumentType)) {
+        continue;
+      }
+      if (best == null || 
best.getParameterTypes()[0].isAssignableFrom(parameterType)) {
+        best = method;
+      }
+    }
+    return best;
+  }
+
   /**
    * Gets instanceId
    *
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 9f35ca20e2..0d571930cd 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
@@ -19,6 +19,7 @@ package org.apache.hop.ui.core.widget.editor;
 
 import org.eclipse.swt.events.ModifyListener;
 import org.eclipse.swt.widgets.Control;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Common interface for a content/code editor widget used in both Hop GUI 
(desktop) and Hop Web.
@@ -29,6 +30,10 @@ import org.eclipse.swt.widgets.Control;
  */
 public interface IContentEditorWidget {
 
+  public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = 
"ContentEditor-Toolbar";
+
+  public static final String GUI_PLUGIN_CONTEXT_MENU_PARENT_ID = 
"ContentEditor-ContextMenu";
+
   /**
    * The SWT control to attach to a layout (e.g. the editor composite or the 
AWT bridge canvas).
    *
@@ -58,6 +63,13 @@ public interface IContentEditorWidget {
    */
   void setTextSuppressModify(String text);
 
+  /**
+   * Get the language used for syntax highlighting and validation.
+   *
+   * @return language identifier (e.g. "json", "xml", "javascript")
+   */
+  @Nullable String getLanguage();
+
   /**
    * Set the language/mode used for syntax highlighting and validation. 
Interpretation is
    * implementation-specific; use lowercase identifiers such as "json", "xml", 
"javascript".
@@ -96,4 +108,16 @@ public interface IContentEditorWidget {
 
   /** Copy selected text to clipboard. */
   void copy();
+
+  /** Cut selected text to clipboard. No-op if not supported by the 
implementation. */
+  void cut();
+
+  /** Paste from clipboard at the caret. No-op if not supported by the 
implementation. */
+  void paste();
+
+  /** Undo the last edit. No-op if not supported by the implementation. */
+  void undo();
+
+  /** Redo the last undone edit. No-op if not supported by the implementation. 
*/
+  void redo();
 }
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
new file mode 100644
index 0000000000..d115e59f9e
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
@@ -0,0 +1,120 @@
+/*
+ * 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 it 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 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.widget.editor.IContentEditorWidget;
+
+/**
+ * Shared content-editor toolbar actions for Hop GUI (desktop) and Hop Web.
+ *
+ * <p>Toolbar items must live on a class present on both classpaths. The RCP 
JFace editor and RAP
+ * Monaco/fallback widgets implement {@link IContentEditorWidget}; listeners 
are static methods
+ * taking that interface so {@link org.apache.hop.ui.core.gui.BaseGuiWidgets} 
can invoke them with
+ * the registered editor instance.
+ *
+ * <p>Context menu and keyboard shortcuts remain on the RCP {@code 
ContentEditorWidget} (desktop
+ * only).
+ */
+@GuiPlugin(name = "Content editor")
+public class ContentEditorActions {
+
+  public static final String ID_TOOLBAR_UNDO = 
"ContentEditor-Toolbar-10000-undo";
+  public static final String ID_TOOLBAR_REDO = 
"ContentEditor-Toolbar-10010-redo";
+  public static final String ID_TOOLBAR_SELECT_ALL = 
"ContentEditor-Toolbar-20000-select-all";
+  public static final String ID_TOOLBAR_UNSELECT_ALL = 
"ContentEditor-Toolbar-20010-unselect-all";
+  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";
+
+  private ContentEditorActions() {}
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_UNDO,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/undo.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Undo.Tooltip")
+  public static void undo(IContentEditorWidget editor) {
+    editor.undo();
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_REDO,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/redo.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Redo.Tooltip")
+  public static void redo(IContentEditorWidget editor) {
+    editor.redo();
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_SELECT_ALL,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/select-all.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.SelectAll.Tooltip",
+      separator = true)
+  public static void selectAll(IContentEditorWidget editor) {
+    editor.selectAll();
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_UNSELECT_ALL,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/unselect-all.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.UnselectAll.Tooltip")
+  public static void unselectAll(IContentEditorWidget editor) {
+    editor.unselectAll();
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_COPY,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/copy.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Copy.Tooltip",
+      separator = true)
+  public static void copy(IContentEditorWidget editor) {
+    editor.copy();
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_CUT,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/cut.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Cut.Tooltip")
+  public static void cut(IContentEditorWidget editor) {
+    editor.cut();
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_TOOLBAR_PASTE,
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/paste.svg",
+      toolTip = "i18n::ContentEditorWidget.ToolBar.Paste.Tooltip")
+  public static void paste(IContentEditorWidget editor) {
+    editor.paste();
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
index 2578a8fe3c..c569a2058d 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
@@ -86,9 +86,11 @@ public class HopGuiEnvironment extends HopClientEnvironment {
         Class<?>[] typeClasses = guiPlugin.getClassMap().keySet().toArray(new 
Class<?>[0]);
         String guiPluginClassName = 
guiPlugin.getClassMap().get(typeClasses[0]);
 
-        // TextDiffDialog is the side-by-side revision compare dialog. Only 
works on SWT
+        // Desktop-only GUI plugins (require SWT types not available under RAP)
         if (EnvironmentUtils.getInstance().isWeb()
-            && "org.apache.hop.git.TextDiffDialog".equals(guiPluginClassName)) 
{
+            && ("org.apache.hop.git.TextDiffDialog".equals(guiPluginClassName)
+                // Content editor JFace implementation lives in hop-ui-rcp only
+                || 
"org.apache.hop.ui.hopgui.ContentEditorWidget".equals(guiPluginClassName))) {
           continue;
         }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 3f968513f6..072e2d1f04 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -2211,10 +2211,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
         });
 
     Composite composite = new Composite(targetFolder, SWT.NONE);
-    FormLayout layoutComposite = new FormLayout();
-    layoutComposite.marginWidth = PropsUi.getFormMargin();
-    layoutComposite.marginHeight = PropsUi.getFormMargin();
-    composite.setLayout(layoutComposite);
+    composite.setLayout(new FormLayout());
     composite.setLayoutData(new FormDataBuilder().fullSize().result());
     PropsUi.setLook(composite);
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
index 3ab23b4cf0..46e62ef8da 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
@@ -48,6 +48,8 @@ public class SqlExplorerFileType extends 
BaseTextExplorerFileType<SqlExplorerFil
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
index 07d4e69aab..f14ed5b17f 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
@@ -48,6 +48,8 @@ public class XmlExplorerFileType extends 
BaseTextExplorerFileType<XmlExplorerFil
             IHopFileType.CAPABILITY_CLOSE,
             IHopFileType.CAPABILITY_FILE_HISTORY,
             IHopFileType.CAPABILITY_COPY,
+            IHopFileType.CAPABILITY_CUT,
+            IHopFileType.CAPABILITY_PASTE,
             IHopFileType.CAPABILITY_SELECT));
   }
 
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 e1a614bfed..0aeb25e71a 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
@@ -326,4 +326,20 @@ HopGui.LayoutCheck.Dialog.MismatchMessage=Details of row 
layout mismatches:
 HopGui.LayoutCheck.Dialog.ReplaceDummyTitle=Replace Transform
 HopGui.LayoutCheck.Dialog.ReplaceDummyMessage=Do you want to replace this 
Dummy transform with a ''Stream Schema Merge'' transform?
 HopGui.LayoutCheck.Dialog.StreamSchemaPluginNotFound=Stream Schema Merge 
plugin not found.
-HopGui.LayoutCheck.Dialog.ReplaceDummyError=Error replacing Dummy transform 
with Stream Schema Merge
\ No newline at end of file
+HopGui.LayoutCheck.Dialog.ReplaceDummyError=Error replacing Dummy transform 
with Stream Schema Merge
+ContentEditorWidget.Menu.Copy=Copy
+ContentEditorWidget.Menu.Cut=Cut
+ContentEditorWidget.Menu.Paste=Paste
+ContentEditorWidget.Menu.Redo=Redo
+ContentEditorWidget.Menu.SelectAll=Select All
+ContentEditorWidget.Menu.UnselectAll=Clear selection
+ContentEditorWidget.Menu.Undo=Undo
+ContentEditorWidget.ToolBar.SelectAll.Tooltip=Select all text
+ContentEditorWidget.ToolBar.UnselectAll.Tooltip=Clear selection
+ContentEditorWidget.ToolBar.Copy.Tooltip=Copy selected text to clipboard
+ContentEditorWidget.ToolBar.Cut.Tooltip=Cut selected text to clipboard
+ContentEditorWidget.ToolBar.Paste.Tooltip=Paste text from clipboard
+ContentEditorWidget.ToolBar.Undo.Tooltip=Undo the last action
+ContentEditorWidget.ToolBar.Redo.Tooltip=Redo the last action
+ContentEditorWidget.ToolBar.Find.Tooltip=Find text
+ContentEditorWidget.ToolBar.Replace.Tooltip=Replace text
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/gui/BaseGuiWidgetsStaticListenerTest.java
 
b/ui/src/test/java/org/apache/hop/ui/core/gui/BaseGuiWidgetsStaticListenerTest.java
new file mode 100644
index 0000000000..5c4c55c57c
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/core/gui/BaseGuiWidgetsStaticListenerTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies static toolbar listener resolution accepts interface/superclass 
parameters (facade
+ * pattern used by the content editor toolbar).
+ */
+class BaseGuiWidgetsStaticListenerTest {
+
+  interface EditorFacade {
+    String id();
+  }
+
+  static final class ConcreteEditor implements EditorFacade {
+    @Override
+    public String id() {
+      return "concrete";
+    }
+  }
+
+  static final class ListenerHost {
+    static EditorFacade lastFacade;
+    static ConcreteEditor lastConcrete;
+
+    public static void onFacade(EditorFacade editor) {
+      lastFacade = editor;
+    }
+
+    public static void onConcrete(ConcreteEditor editor) {
+      lastConcrete = editor;
+    }
+
+    public static void ignored(String other) {
+      // not a match for EditorFacade argument
+    }
+  }
+
+  @Test
+  void findsStaticMethodWithInterfaceParameter() throws Exception {
+    Method method =
+        BaseGuiWidgets.findStaticListenerMethod(
+            ListenerHost.class, "onFacade", ConcreteEditor.class);
+    assertNotNull(method);
+    assertEquals("onFacade", method.getName());
+
+    ConcreteEditor editor = new ConcreteEditor();
+    method.invoke(null, editor);
+    assertSame(editor, ListenerHost.lastFacade);
+  }
+
+  @Test
+  void prefersMostSpecificParameterType() {
+    Method method =
+        BaseGuiWidgets.findStaticListenerMethod(
+            ListenerHost.class, "onConcrete", ConcreteEditor.class);
+    assertNotNull(method);
+    assertEquals(ConcreteEditor.class, method.getParameterTypes()[0]);
+  }
+
+  @Test
+  void returnsNullWhenNoAssignableParameter() {
+    Method method =
+        BaseGuiWidgets.findStaticListenerMethod(
+            ListenerHost.class, "ignored", ConcreteEditor.class);
+    assertNull(method);
+  }
+}


Reply via email to