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 e397e01611 Add markdown support and live preview, fixes #7876 (#7949)
e397e01611 is described below

commit e397e0161169d0b47e0f5c8a5c4f00db61845c90
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Aug 14 12:19:38 2026 +0200

    Add markdown support and live preview, fixes #7876 (#7949)
    
    * Add markdown support and live preview, fixes #7876
    
    * delete scratch test
---
 .../types/MarkDownExplorerFileTypeHandler.java     | 507 ++++++++++++++-------
 .../types/MarkDownPreviewExplorerFileType.java     |  64 +++
 .../MarkDownPreviewExplorerFileTypeHandler.java    | 117 +++++
 .../types/messages/messages_en_US.properties       |   4 +-
 ...MarkDownPreviewExplorerFileTypeHandlerTest.java | 101 ++++
 .../types/MarkDownPreviewExplorerFileTypeTest.java |  71 +++
 .../hop/ui/hopgui/ContentEditorTm4eSupport.java    |  78 +++-
 .../hopgui/RuleBasedSourceViewerConfiguration.java |   8 +-
 .../apache/hop/ui/hopgui/grammars/markdown.json    | 178 ++++++++
 .../ui/hopgui/ContentEditorTm4eSupportTest.java    | 101 ++++
 10 files changed, 1046 insertions(+), 183 deletions(-)

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 e298df74af..288bf0c984 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
@@ -18,27 +18,23 @@
 
 package org.apache.hop.pipeline.transforms.types;
 
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
 import java.util.List;
+import org.apache.hop.core.Const;
 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.core.util.TempFileUtil;
+import org.apache.hop.i18n.BaseMessages;
 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.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.perspective.TabItemHandler;
 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;
-import org.apache.hop.ui.util.EnvironmentUtils;
 import org.commonmark.Extension;
 import org.commonmark.ext.footnotes.FootnotesExtension;
 import org.commonmark.ext.gfm.tables.TablesExtension;
@@ -46,7 +42,9 @@ 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.custom.CTabItem;
 import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
 
 /** How do we handle a markdown file in the file explorer perspective? */
 @GuiPlugin(name = "Markdown file type handler")
@@ -63,6 +61,20 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
       List.of(
           TablesExtension.create(), TaskListItemsExtension.create(), 
FootnotesExtension.create());
 
+  /** Key under which the editor control points back at the handler that 
created it. */
+  private static final String EDITOR_DATA_HANDLER = 
"MarkDownExplorerFileTypeHandler";
+
+  /** How long typing has to pause before the open preview is re-rendered. */
+  private static final int PREVIEW_REFRESH_DELAY_MS = 400;
+
+  /** The tab showing the rendered preview of this file, as long as it is 
open. */
+  private MarkDownPreviewExplorerFileTypeHandler previewHandler;
+
+  /** The tab item we're watching for the close that ends this file's preview. 
*/
+  private CTabItem trackedTabItem;
+
+  private Runnable pendingPreviewRefresh;
+
   public MarkDownExplorerFileTypeHandler(
       HopGui hopGui, ExplorerPerspective perspective, ExplorerFile 
explorerFile) {
     super(hopGui, perspective, explorerFile);
@@ -80,6 +92,10 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
     editorWidget = ContentEditorFacade.createContentEditor(composite, 
getLanguageId());
     
editorWidget.getControl().setLayoutData(FormDataBuilder.builder().fullSize().build());
 
+    // The toolbar hands the preview action nothing but the editor widget, so 
leave a way back
+    // to this handler on the control itself.
+    editorWidget.getControl().setData(EDITOR_DATA_HANDLER, this);
+
     // If it's a new file, there's no need to reload it
     if (this.getFilename() != null) {
       reload();
@@ -92,6 +108,108 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
             this.setChanged();
             perspective.updateGui();
           }
+          schedulePreviewRefresh();
+        });
+  }
+
+  /**
+   * Re-renders the preview once typing pauses, so the tab keeps up with the 
editor without
+   * re-rendering on every keystroke. Does nothing when no preview tab is open.
+   */
+  private void schedulePreviewRefresh() {
+    if (previewHandler == null || !previewHandler.isOpen()) {
+      return;
+    }
+    Display display = hopGui.getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    if (pendingPreviewRefresh != null) {
+      display.timerExec(-1, pendingPreviewRefresh);
+    }
+    pendingPreviewRefresh =
+        () -> {
+          pendingPreviewRefresh = null;
+          refreshPreview();
+        };
+    display.timerExec(PREVIEW_REFRESH_DELAY_MS, pendingPreviewRefresh);
+  }
+
+  /** Renders the current editor content into the preview tab, leaving the 
focus where it is. */
+  private void refreshPreview() {
+    if (previewHandler == null || !previewHandler.isOpen() || isEditorGone()) {
+      return;
+    }
+    previewHandler.setHtml(renderHtml(editorWidget.getText()));
+    trackTabClose();
+  }
+
+  private boolean isEditorGone() {
+    return editorWidget == null
+        || editorWidget.getControl() == null
+        || editorWidget.getControl().isDisposed();
+  }
+
+  /**
+   * Watches this file's tab item for the dispose that means "closed", so the 
preview can go with
+   * it. Closing a tab disposes its {@link CTabItem} but not the control it 
shows, so the item is
+   * the only thing that tells us. A tab moved to another pane is disposed as 
well, but there the
+   * perspective still lists this handler, which is how the two are told apart.
+   */
+  private void trackTabClose() {
+    CTabItem tabItem = findTabItem();
+    if (tabItem == null || tabItem == trackedTabItem) {
+      return;
+    }
+    trackedTabItem = tabItem;
+    tabItem.addDisposeListener(
+        e -> {
+          if (!hasOpenTab()) {
+            closePreview();
+          }
+        });
+  }
+
+  /**
+   * True while the perspective still has a tab for this file. It stops being 
true just before the
+   * tab item is disposed on close, while a tab moved to another pane stays 
listed throughout.
+   */
+  boolean hasOpenTab() {
+    return MarkDownPreviewExplorerFileTypeHandler.isTabOpen(perspective, this);
+  }
+
+  /** This file's tab item, or null when the perspective no longer has a tab 
for it. */
+  private CTabItem findTabItem() {
+    for (TabItemHandler item : perspective.getItems()) {
+      if (item.getTypeHandler() == this) {
+        return item.getTabItem();
+      }
+    }
+    return null;
+  }
+
+  /** Closes the preview tab of this file, if one is open. */
+  private void closePreview() {
+    MarkDownPreviewExplorerFileTypeHandler preview = previewHandler;
+    previewHandler = null;
+    if (preview == null) {
+      return;
+    }
+    if (!preview.isOpen()) {
+      preview.disposeWidgets();
+      return;
+    }
+    // We're inside the dispose of our own tab item: let that finish before 
removing another tab.
+    Display display = hopGui.getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.asyncExec(
+        () -> {
+          if (preview.isOpen()) {
+            perspective.remove(preview);
+          }
+          preview.disposeWidgets();
         });
   }
 
@@ -113,190 +231,241 @@ public class MarkDownExplorerFileTypeHandler extends 
BaseTextExplorerFileTypeHan
       separator = true)
   public static void previewMarkdown(IContentEditorWidget editorWidget) {
     try {
-      String markdown = editorWidget.getText();
+      String html = renderHtml(editorWidget.getText());
+
+      MarkDownExplorerFileTypeHandler handler = findHandlerOf(editorWidget);
+      if (handler != null) {
+        handler.showPreview(html);
+      } else {
+        // Editor without a Markdown tab behind it: still show the render, 
just not tied to a file
+        openPreviewTab(html, BaseMessages.getString(PKG, 
"MarkDownFileTypeHandler.Preview.Tab"));
+      }
+    } catch (Exception e) {
+      new ErrorDialog(
+          HopGui.getInstance().getActiveShell(),
+          "Error",
+          "Error generating or displaying Markdown preview",
+          e);
+    }
+  }
+
+  /**
+   * The handler whose editor this is. The toolbar action is static and gets 
only the widget that
+   * was clicked, and the widget knows which handler built it, which beats 
guessing from whichever
+   * tab is currently active.
+   */
+  private static MarkDownExplorerFileTypeHandler 
findHandlerOf(IContentEditorWidget editorWidget) {
+    if (editorWidget == null
+        || editorWidget.getControl() == null
+        || editorWidget.getControl().isDisposed()) {
+      return null;
+    }
+    Object handler = editorWidget.getControl().getData(EDITOR_DATA_HANDLER);
+    return handler instanceof MarkDownExplorerFileTypeHandler markdownHandler
+        ? markdownHandler
+        : null;
+  }
+
+  /**
+   * Opens the preview of this file in its own tab, or refreshes and selects 
the tab that is already
+   * showing it.
+   */
+  private void showPreview(String html) {
+    if (previewHandler != null && !previewHandler.isOpen()) {
+      // The preview tab was closed in the meantime. Closing a tab leaves its 
widgets behind, so
+      // clean those up before opening a new one.
+      previewHandler.disposeWidgets();
+      previewHandler = null;
+    }
+    if (previewHandler == null) {
+      previewHandler =
+          openPreviewTab(
+              html,
+              BaseMessages.getString(
+                  PKG, "MarkDownFileTypeHandler.Preview.TabForFile", 
Const.NVL(getName(), "")));
+    } else {
+      // Already open: refresh it and bring it up front
+      previewHandler.setHtml(html);
+      perspective.setActiveFileTypeHandler(previewHandler);
+    }
+    trackTabClose();
+  }
+
+  private static MarkDownPreviewExplorerFileTypeHandler openPreviewTab(
+      String html, String tabName) {
+    ExplorerPerspective explorerPerspective = 
ExplorerPerspective.getInstance();
+
+    // No filename: the preview isn't a file on disk, it's a rendering of the 
editor content.
+    ExplorerFile previewFile =
+        new ExplorerFile(tabName, null, new MarkDownPreviewExplorerFileType());
+    MarkDownPreviewExplorerFileTypeHandler handler =
+        new MarkDownPreviewExplorerFileTypeHandler(
+            HopGui.getInstance(), explorerPerspective, previewFile);
+    handler.setHtml(html);
 
-      // Parse markdown to HTML body content
-      Parser parser = Parser.builder().extensions(MARKDOWN_EXTENSIONS).build();
-      HtmlRenderer renderer = 
HtmlRenderer.builder().extensions(MARKDOWN_EXTENSIONS).build();
-      Node document = parser.parse(markdown);
-      String htmlContent = renderer.render(document);
+    explorerPerspective.addFile(handler);
+    return handler;
+  }
+
+  /** Renders Markdown to a full HTML page, styled to match the Hop GUI light 
or dark theme. */
+  private static String renderHtml(String markdown) {
+    // Parse markdown to HTML body content
+    Parser parser = Parser.builder().extensions(MARKDOWN_EXTENSIONS).build();
+    HtmlRenderer renderer = 
HtmlRenderer.builder().extensions(MARKDOWN_EXTENSIONS).build();
+    Node document = parser.parse(markdown);
+    String htmlContent = renderer.render(document);
+
+    // Wrap the content with styled CSS, supporting dark mode if configured
+    StringBuilder html = new StringBuilder();
+    html.append("<!DOCTYPE html>\n<html>\n<head>\n<meta 
charset=\"UTF-8\">\n<style>\n");
+    html.append("body {\n");
+    html.append(
+        "  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe 
UI', Roboto, Helvetica, Arial, sans-serif;\n");
+    html.append("  line-height: 1.625;\n");
+    html.append("  margin: 40px auto;\n");
+    html.append("  max-width: 800px;\n");
+    html.append("  padding: 0 20px;\n");
+    html.append("}\n");
+    html.append("h1, h2, h3, h4, h5, h6 {\n");
+    html.append("  font-weight: 600;\n");
+    html.append("}\n");
+    html.append("h1 {\n");
+    html.append("  font-size: 2.25rem;\n");
+    html.append("  padding-bottom: 0.3em;\n");
+    html.append("  border-bottom: 1px solid;\n");
+    html.append("}\n");
+    html.append("h2 {\n");
+    html.append("  font-size: 1.5rem;\n");
+    html.append("  padding-bottom: 0.3em;\n");
+    html.append("  border-bottom: 1px solid;\n");
+    html.append("}\n");
+    html.append("a {\n");
+    html.append("  text-decoration: none;\n");
+    html.append("  font-weight: 500;\n");
+    html.append("}\n");
+    html.append("a:hover {\n");
+    html.append("  text-decoration: underline;\n");
+    html.append("}\n");
+    html.append("pre, code {\n");
+    html.append("  font-family: SFMono-Regular, Consolas, 'Liberation Mono', 
Menlo, monospace;\n");
+    html.append("  font-size: 0.9em;\n");
+    html.append("  border-radius: 6px;\n");
+    html.append("}\n");
+    html.append("code {\n");
+    html.append("  padding: 0.2em 0.4em;\n");
+    html.append("}\n");
+    html.append("pre {\n");
+    html.append("  padding: 16px;\n");
+    html.append("  overflow-x: auto;\n");
+    html.append("}\n");
+    html.append("pre code {\n");
+    html.append("  padding: 0;\n");
+    html.append("  background-color: transparent;\n");
+    html.append("  border-radius: 0;\n");
+    html.append("  border: none;\n");
+    html.append("}\n");
+    html.append("blockquote {\n");
+    html.append("  margin: 1.5em 0;\n");
+    html.append("  padding: 0.5em 1em;\n");
+    html.append("  border-left-width: 4px;\n");
+    html.append("  border-left-style: solid;\n");
+    html.append("}\n");
+    html.append("table {\n");
+    html.append("  width: 100%;\n");
+    html.append("  margin: 1.5em 0;\n");
+    html.append("  border-collapse: collapse;\n");
+    html.append("  border-radius: 6px;\n");
+    html.append("  overflow: hidden;\n");
+    html.append("  font-size: 0.95em;\n");
+    html.append("}\n");
+    html.append("th, td {\n");
+    html.append("  padding: 0.6em 0.85em;\n");
+    html.append("  border: 1px solid;\n");
+    html.append("  text-align: left;\n");
+    html.append("}\n");
+    html.append("th {\n");
+    html.append("  font-weight: 600;\n");
+    html.append("}\n");
 
-      // Wrap the content with styled CSS, supporting dark mode if configured
-      StringBuilder html = new StringBuilder();
-      html.append("<!DOCTYPE html>\n<html>\n<head>\n<meta 
charset=\"UTF-8\">\n<style>\n");
+    if (PropsUi.getInstance().isDarkMode()) {
       html.append("body {\n");
-      html.append(
-          "  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe 
UI', Roboto, Helvetica, Arial, sans-serif;\n");
-      html.append("  line-height: 1.625;\n");
-      html.append("  margin: 40px auto;\n");
-      html.append("  max-width: 800px;\n");
-      html.append("  padding: 0 20px;\n");
+      html.append("  background-color: #0b0f19;\n");
+      html.append("  color: #94a3b8;\n");
       html.append("}\n");
       html.append("h1, h2, h3, h4, h5, h6 {\n");
-      html.append("  font-weight: 600;\n");
+      html.append("  color: #f8fafc;\n");
       html.append("}\n");
-      html.append("h1 {\n");
-      html.append("  font-size: 2.25rem;\n");
-      html.append("  padding-bottom: 0.3em;\n");
-      html.append("  border-bottom: 1px solid;\n");
-      html.append("}\n");
-      html.append("h2 {\n");
-      html.append("  font-size: 1.5rem;\n");
-      html.append("  padding-bottom: 0.3em;\n");
-      html.append("  border-bottom: 1px solid;\n");
+      html.append("h1, h2 {\n");
+      html.append("  border-bottom-color: #1e293b;\n");
       html.append("}\n");
       html.append("a {\n");
-      html.append("  text-decoration: none;\n");
-      html.append("  font-weight: 500;\n");
-      html.append("}\n");
-      html.append("a:hover {\n");
-      html.append("  text-decoration: underline;\n");
+      html.append("  color: #38bdf8;\n");
       html.append("}\n");
       html.append("pre, code {\n");
-      html.append(
-          "  font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, 
monospace;\n");
-      html.append("  font-size: 0.9em;\n");
-      html.append("  border-radius: 6px;\n");
+      html.append("  background-color: #1e293b;\n");
+      html.append("  border: 1px solid #334155;\n");
+      html.append("  color: #e2e8f0;\n");
+      html.append("}\n");
+      html.append("blockquote {\n");
+      html.append("  border-left-color: #475569;\n");
+      html.append("  color: #94a3b8;\n");
+      html.append("  background-color: #0f172a;\n");
+      html.append("}\n");
+      html.append("table {\n");
+      html.append("  background-color: #0f172a;\n");
+      html.append("}\n");
+      html.append("th, td {\n");
+      html.append("  border-color: #334155;\n");
       html.append("}\n");
-      html.append("code {\n");
-      html.append("  padding: 0.2em 0.4em;\n");
+      html.append("th {\n");
+      html.append("  background-color: #1e293b;\n");
+      html.append("  color: #f8fafc;\n");
+      html.append("}\n");
+      html.append("tbody tr:nth-child(even) {\n");
+      html.append("  background-color: #111827;\n");
+      html.append("}\n");
+    } else {
+      html.append("body {\n");
+      html.append("  background-color: #f8fafc;\n");
+      html.append("  color: #334155;\n");
       html.append("}\n");
-      html.append("pre {\n");
-      html.append("  padding: 16px;\n");
-      html.append("  overflow-x: auto;\n");
+      html.append("h1, h2, h3, h4, h5, h6 {\n");
+      html.append("  color: #0f172a;\n");
       html.append("}\n");
-      html.append("pre code {\n");
-      html.append("  padding: 0;\n");
-      html.append("  background-color: transparent;\n");
-      html.append("  border-radius: 0;\n");
-      html.append("  border: none;\n");
+      html.append("h1, h2 {\n");
+      html.append("  border-bottom-color: #e2e8f0;\n");
+      html.append("}\n");
+      html.append("a {\n");
+      html.append("  color: #2563eb;\n");
+      html.append("}\n");
+      html.append("pre, code {\n");
+      html.append("  background-color: #f1f5f9;\n");
+      html.append("  border: 1px solid #e2e8f0;\n");
+      html.append("  color: #334155;\n");
       html.append("}\n");
       html.append("blockquote {\n");
-      html.append("  margin: 1.5em 0;\n");
-      html.append("  padding: 0.5em 1em;\n");
-      html.append("  border-left-width: 4px;\n");
-      html.append("  border-left-style: solid;\n");
+      html.append("  border-left-color: #cbd5e1;\n");
+      html.append("  color: #64748b;\n");
+      html.append("  background-color: #f8fafc;\n");
       html.append("}\n");
       html.append("table {\n");
-      html.append("  width: 100%;\n");
-      html.append("  margin: 1.5em 0;\n");
-      html.append("  border-collapse: collapse;\n");
-      html.append("  border-radius: 6px;\n");
-      html.append("  overflow: hidden;\n");
-      html.append("  font-size: 0.95em;\n");
+      html.append("  background-color: #ffffff;\n");
       html.append("}\n");
       html.append("th, td {\n");
-      html.append("  padding: 0.6em 0.85em;\n");
-      html.append("  border: 1px solid;\n");
-      html.append("  text-align: left;\n");
+      html.append("  border-color: #e2e8f0;\n");
       html.append("}\n");
       html.append("th {\n");
-      html.append("  font-weight: 600;\n");
+      html.append("  background-color: #f1f5f9;\n");
+      html.append("  color: #0f172a;\n");
+      html.append("}\n");
+      html.append("tbody tr:nth-child(even) {\n");
+      html.append("  background-color: #f8fafc;\n");
       html.append("}\n");
-
-      if (PropsUi.getInstance().isDarkMode()) {
-        html.append("body {\n");
-        html.append("  background-color: #0b0f19;\n");
-        html.append("  color: #94a3b8;\n");
-        html.append("}\n");
-        html.append("h1, h2, h3, h4, h5, h6 {\n");
-        html.append("  color: #f8fafc;\n");
-        html.append("}\n");
-        html.append("h1, h2 {\n");
-        html.append("  border-bottom-color: #1e293b;\n");
-        html.append("}\n");
-        html.append("a {\n");
-        html.append("  color: #38bdf8;\n");
-        html.append("}\n");
-        html.append("pre, code {\n");
-        html.append("  background-color: #1e293b;\n");
-        html.append("  border: 1px solid #334155;\n");
-        html.append("  color: #e2e8f0;\n");
-        html.append("}\n");
-        html.append("blockquote {\n");
-        html.append("  border-left-color: #475569;\n");
-        html.append("  color: #94a3b8;\n");
-        html.append("  background-color: #0f172a;\n");
-        html.append("}\n");
-        html.append("table {\n");
-        html.append("  background-color: #0f172a;\n");
-        html.append("}\n");
-        html.append("th, td {\n");
-        html.append("  border-color: #334155;\n");
-        html.append("}\n");
-        html.append("th {\n");
-        html.append("  background-color: #1e293b;\n");
-        html.append("  color: #f8fafc;\n");
-        html.append("}\n");
-        html.append("tbody tr:nth-child(even) {\n");
-        html.append("  background-color: #111827;\n");
-        html.append("}\n");
-      } else {
-        html.append("body {\n");
-        html.append("  background-color: #f8fafc;\n");
-        html.append("  color: #334155;\n");
-        html.append("}\n");
-        html.append("h1, h2, h3, h4, h5, h6 {\n");
-        html.append("  color: #0f172a;\n");
-        html.append("}\n");
-        html.append("h1, h2 {\n");
-        html.append("  border-bottom-color: #e2e8f0;\n");
-        html.append("}\n");
-        html.append("a {\n");
-        html.append("  color: #2563eb;\n");
-        html.append("}\n");
-        html.append("pre, code {\n");
-        html.append("  background-color: #f1f5f9;\n");
-        html.append("  border: 1px solid #e2e8f0;\n");
-        html.append("  color: #334155;\n");
-        html.append("}\n");
-        html.append("blockquote {\n");
-        html.append("  border-left-color: #cbd5e1;\n");
-        html.append("  color: #64748b;\n");
-        html.append("  background-color: #f8fafc;\n");
-        html.append("}\n");
-        html.append("table {\n");
-        html.append("  background-color: #ffffff;\n");
-        html.append("}\n");
-        html.append("th, td {\n");
-        html.append("  border-color: #e2e8f0;\n");
-        html.append("}\n");
-        html.append("th {\n");
-        html.append("  background-color: #f1f5f9;\n");
-        html.append("  color: #0f172a;\n");
-        html.append("}\n");
-        html.append("tbody tr:nth-child(even) {\n");
-        html.append("  background-color: #f8fafc;\n");
-        html.append("}\n");
-      }
-      html.append("</style>\n</head>\n<body>\n");
-      html.append(htmlContent);
-      html.append("\n</body>\n</html>");
-
-      String fullHtml = html.toString();
-
-      // 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 = TempFileUtil.createTempFileObject("markdown_preview_", 
".html");
-        tempFile.deleteOnExit();
-        try (OutputStream outputStream = new FileOutputStream(tempFile)) {
-          outputStream.write(fullHtml.getBytes(StandardCharsets.UTF_8));
-        }
-        EnvironmentUtils.getInstance().openUrl(tempFile.toURI().toString());
-      }
-    } catch (Exception e) {
-      new ErrorDialog(
-          HopGui.getInstance().getActiveShell(),
-          "Error",
-          "Error generating or displaying Markdown preview",
-          e);
     }
+    html.append("</style>\n</head>\n<body>\n");
+    html.append(htmlContent);
+    html.append("\n</body>\n</html>");
+
+    return html.toString();
   }
 }
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileType.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileType.java
new file mode 100644
index 0000000000..5b34d291f1
--- /dev/null
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileType.java
@@ -0,0 +1,64 @@
+/*
+ * 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.pipeline.transforms.types;
+
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.HopFileTypePlugin;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+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.capabilities.FileTypeCapabilities;
+import 
org.apache.hop.ui.hopgui.perspective.explorer.file.types.base.BaseExplorerFileType;
+
+/**
+ * The rendered preview of a Markdown file. This type never matches a file on 
disk: it only exists
+ * so that the preview of a .md file can live in its own tab, opened from the 
content editor toolbar
+ * of {@link MarkDownExplorerFileTypeHandler}.
+ */
+@HopFileTypePlugin(
+    id = "MarkDownPreviewExplorerFileType",
+    name = "MarkDown Preview File Type",
+    description = "Rendered MarkDown preview in the explorer perspective",
+    image = "markdown.svg")
+public class MarkDownPreviewExplorerFileType
+    extends BaseExplorerFileType<MarkDownPreviewExplorerFileTypeHandler> {
+
+  public MarkDownPreviewExplorerFileType() {
+    super(
+        "MarkDown Preview",
+        "",
+        new String[] {},
+        new String[] {},
+        FileTypeCapabilities.getCapabilities(IHopFileType.CAPABILITY_CLOSE));
+  }
+
+  @Override
+  public MarkDownPreviewExplorerFileTypeHandler createFileTypeHandler(
+      HopGui hopGui, ExplorerPerspective perspective, ExplorerFile file) {
+    return new MarkDownPreviewExplorerFileTypeHandler(hopGui, perspective, 
file);
+  }
+
+  @Override
+  public IHopFileTypeHandler newFile(HopGui hopGui, IVariables 
parentVariableSpace) {
+    return new EmptyHopFileTypeHandler();
+  }
+}
diff --git 
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeHandler.java
 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeHandler.java
new file mode 100644
index 0000000000..a27f0388e5
--- /dev/null
+++ 
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeHandler.java
@@ -0,0 +1,117 @@
+/*
+ * 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.pipeline.transforms.types;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+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.base.BaseExplorerFileTypeHandler;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.browser.Browser;
+import org.eclipse.swt.widgets.Composite;
+
+/**
+ * Shows the rendered HTML of a Markdown file in its own explorer tab. The 
content comes from the
+ * editor buffer of the Markdown tab it was opened from, so it is a snapshot: 
previewing again
+ * refreshes this tab instead of opening a second one.
+ */
+public class MarkDownPreviewExplorerFileTypeHandler extends 
BaseExplorerFileTypeHandler {
+
+  private Browser wBrowser;
+  private String html;
+
+  public MarkDownPreviewExplorerFileTypeHandler(
+      HopGui hopGui, ExplorerPerspective perspective, ExplorerFile 
explorerFile) {
+    super(hopGui, perspective, explorerFile);
+  }
+
+  @Override
+  public void renderFile(Composite composite) {
+    wBrowser = new Browser(composite, SWT.NONE);
+    PropsUi.setLook(wBrowser);
+    wBrowser.setLayoutData(FormDataBuilder.builder().fullSize().build());
+
+    reload();
+  }
+
+  /**
+   * Whether the perspective still has a tab for the given handler. Closing a 
tab disposes its
+   * {@link org.eclipse.swt.custom.CTabItem} but not the control it shows, so 
widgets outlive the
+   * tab and can't answer this: the perspective's own list of tabs is what 
counts.
+   */
+  static boolean isTabOpen(ExplorerPerspective perspective, 
IHopFileTypeHandler handler) {
+    for (TabItemHandler item : perspective.getItems()) {
+      if (item.getTypeHandler() == handler) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** True as long as the preview tab is open, false once it has been closed. 
*/
+  public boolean isOpen() {
+    return isTabOpen(perspective, this);
+  }
+
+  /** Disposes the widgets left behind after the tab was closed. */
+  public void disposeWidgets() {
+    if (hasBrowser()) {
+      wBrowser.getParent().dispose();
+    }
+    wBrowser = null;
+  }
+
+  /** Sets the HTML to show. Can be called before the tab is rendered. */
+  public void setHtml(String html) {
+    this.html = html;
+    reload();
+  }
+
+  @Override
+  public void reload() {
+    if (hasBrowser()) {
+      wBrowser.setText(Const.NVL(html, ""));
+      clearChanged();
+    }
+  }
+
+  private boolean hasBrowser() {
+    return wBrowser != null && !wBrowser.isDisposed();
+  }
+
+  @Override
+  public void selectAll() {
+    // The browser widget has no selection API we can drive from here
+  }
+
+  @Override
+  public void unselectAll() {
+    // The browser widget has no selection API we can drive from here
+  }
+
+  @Override
+  public void copySelectedToClipboard() {
+    // The browser widget has no selection API we can drive from here
+  }
+}
diff --git 
a/plugins/transforms/textfile/src/main/resources/org/apache/hop/pipeline/transforms/types/messages/messages_en_US.properties
 
b/plugins/transforms/textfile/src/main/resources/org/apache/hop/pipeline/transforms/types/messages/messages_en_US.properties
index c5e97cfede..65e8b18030 100644
--- 
a/plugins/transforms/textfile/src/main/resources/org/apache/hop/pipeline/transforms/types/messages/messages_en_US.properties
+++ 
b/plugins/transforms/textfile/src/main/resources/org/apache/hop/pipeline/transforms/types/messages/messages_en_US.properties
@@ -20,4 +20,6 @@ MarkDownFileType.GuiAction.NewMarkDown.Name=Markdown
 MarkDownFileType.GuiAction.NewMarkDown.Tooltip=Create a new Markdown file
 MarkDownFileType.ErrorDialog.NewMarkDownCreation.Header=Error creating 
Markdown file
 MarkDownFileType.ErrorDialog.NewMarkDownCreation.Message=An error occurred 
while creating the Markdown file:
-MarkDownFileTypeHandler.Preview.Tooltip=Preview the HTML version of this 
Markdown file in a browser
+MarkDownFileTypeHandler.Preview.Tooltip=Show the rendered version of this 
Markdown file in a preview tab
+MarkDownFileTypeHandler.Preview.Tab=Markdown preview
+MarkDownFileTypeHandler.Preview.TabForFile={0} (preview)
diff --git 
a/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeHandlerTest.java
 
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeHandlerTest.java
new file mode 100644
index 0000000000..a5e293b0d1
--- /dev/null
+++ 
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeHandlerTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.pipeline.transforms.types;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class MarkDownPreviewExplorerFileTypeHandlerTest {
+
+  private ExplorerPerspective perspective;
+  private MarkDownPreviewExplorerFileTypeHandler handler;
+
+  @BeforeEach
+  void setUp() {
+    perspective = mock(ExplorerPerspective.class);
+    handler =
+        new MarkDownPreviewExplorerFileTypeHandler(
+            null,
+            perspective,
+            new ExplorerFile("notes.md (preview)", null, new 
MarkDownPreviewExplorerFileType()));
+  }
+
+  @Test
+  void testNotOpenBeforeTheTabIsAdded() {
+    when(perspective.getItems()).thenReturn(List.of());
+
+    assertFalse(handler.isOpen());
+  }
+
+  @Test
+  void testOpenWhileThePerspectiveHasATabForIt() {
+    when(perspective.getItems()).thenReturn(List.of(new TabItemHandler(null, 
handler)));
+
+    assertTrue(handler.isOpen());
+  }
+
+  @Test
+  void testClosedOnceThePerspectiveDropsTheTab() {
+    // Closing a tab disposes the CTabItem but leaves the browser widget 
alive, so a widget check
+    // would keep reporting the preview as open and previewing again would do 
nothing.
+    when(perspective.getItems()).thenReturn(List.of(new TabItemHandler(null, 
handler)));
+    assertTrue(handler.isOpen());
+
+    when(perspective.getItems()).thenReturn(List.of());
+
+    assertFalse(handler.isOpen());
+  }
+
+  @Test
+  void testTheSourceFileTabIsTrackedTheSameWay() {
+    // The markdown tab uses the same rule to tell "my tab was closed" 
(dropped from the list, which
+    // happens just before the tab item is disposed) from "my tab was moved to 
another pane" (still
+    // listed, tab item disposed and replaced).
+    MarkDownExplorerFileTypeHandler markdownHandler =
+        new MarkDownExplorerFileTypeHandler(
+            null, perspective, new ExplorerFile("notes.md", null, new 
MarkDownExplorerFileType()));
+
+    when(perspective.getItems()).thenReturn(List.of(new TabItemHandler(null, 
markdownHandler)));
+    assertTrue(markdownHandler.hasOpenTab());
+
+    when(perspective.getItems()).thenReturn(List.of());
+    assertFalse(markdownHandler.hasOpenTab());
+  }
+
+  @Test
+  void testOtherTabsDoNotCountAsThePreview() {
+    MarkDownPreviewExplorerFileTypeHandler otherPreview =
+        new MarkDownPreviewExplorerFileTypeHandler(
+            null,
+            perspective,
+            new ExplorerFile("other.md (preview)", null, new 
MarkDownPreviewExplorerFileType()));
+    when(perspective.getItems()).thenReturn(List.of(new TabItemHandler(null, 
otherPreview)));
+
+    assertFalse(handler.isOpen());
+  }
+}
diff --git 
a/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeTest.java
 
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeTest.java
new file mode 100644
index 0000000000..df1b5fa391
--- /dev/null
+++ 
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/types/MarkDownPreviewExplorerFileTypeTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.hop.pipeline.transforms.types;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class MarkDownPreviewExplorerFileTypeTest {
+
+  private MarkDownPreviewExplorerFileType fileType;
+
+  @BeforeEach
+  void setUp() {
+    fileType = new MarkDownPreviewExplorerFileType();
+  }
+
+  @Test
+  void testGetName() {
+    assertEquals("MarkDown Preview", fileType.getName());
+  }
+
+  @Test
+  void testHasNoFileExtensions() {
+    // The preview tab renders editor content, it is never a file on disk
+    assertNotNull(fileType.getFilterExtensions());
+    assertEquals(0, fileType.getFilterExtensions().length);
+    assertNotNull(fileType.getFilterNames());
+    assertEquals(0, fileType.getFilterNames().length);
+  }
+
+  @Test
+  void testDoesNotHandleFilesOnDisk() throws Exception {
+    assertFalse(fileType.isHandledBy("notes.md", false));
+    assertFalse(fileType.isHandledBy("notes.txt", false));
+  }
+
+  @Test
+  void testCanOnlyBeClosed() {
+    assertTrue(fileType.hasCapability(IHopFileType.CAPABILITY_CLOSE));
+    assertFalse(fileType.hasCapability(IHopFileType.CAPABILITY_NEW));
+    assertFalse(fileType.hasCapability(IHopFileType.CAPABILITY_SAVE));
+    assertFalse(fileType.hasCapability(IHopFileType.CAPABILITY_SAVE_AS));
+  }
+
+  @Test
+  void testHasImageForTheTabIcon() {
+    assertEquals("markdown.svg", fileType.getFileTypeImage());
+  }
+}
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
index e00948b071..928c0765ec 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
@@ -24,6 +24,7 @@ import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.hop.ui.core.PropsUi;
@@ -31,6 +32,7 @@ import org.eclipse.jface.text.IDocument;
 import org.eclipse.jface.text.TextAttribute;
 import org.eclipse.jface.text.rules.IToken;
 import org.eclipse.jface.text.rules.Token;
+import org.eclipse.swt.SWT;
 import org.eclipse.swt.graphics.Color;
 import org.eclipse.swt.graphics.RGB;
 import org.eclipse.swt.widgets.Display;
@@ -58,6 +60,7 @@ final class ContentEditorTm4eSupport {
   private static final String SCOPE_YAML = "source.yaml";
   private static final String SCOPE_SHELL = "source.shell";
   private static final String SCOPE_BATCH = "source.batchfile";
+  private static final String SCOPE_MARKDOWN = "text.html.markdown";
 
   /** Maps TM4E scope names to grammar resource filenames (classpath-relative 
to grammars/). */
   private static final Map<String, String> GRAMMAR_FILES =
@@ -69,7 +72,8 @@ final class ContentEditorTm4eSupport {
           SCOPE_PYTHON, "python.json",
           SCOPE_YAML, "yaml.json",
           SCOPE_SHELL, "shell.json",
-          SCOPE_BATCH, "bat.json");
+          SCOPE_BATCH, "bat.json",
+          SCOPE_MARKDOWN, "markdown.json");
 
   // Same palette as before (light/dark) for consistency
   private static final RGB L_COMMENT = new RGB(128, 128, 128);
@@ -94,6 +98,8 @@ final class ContentEditorTm4eSupport {
 
   private final Registry registry;
   private final Display display;
+  private final Map<String, TextAttribute> attributeCache = new HashMap<>();
+  private final Map<RGB, Color> colorCache = new HashMap<>();
 
   private ContentEditorTm4eSupport(Display display) {
     this.display = display != null && !display.isDisposed() ? display : 
Display.getDefault();
@@ -122,6 +128,7 @@ final class ContentEditorTm4eSupport {
       case "yaml", "yml" -> SCOPE_YAML;
       case "shell", "bash", "sh" -> SCOPE_SHELL;
       case "bat", "cmd", "batch" -> SCOPE_BATCH;
+      case "markdown", "md" -> SCOPE_MARKDOWN;
       case "plaintext" -> SCOPE_TEXT;
       default -> null;
     };
@@ -216,18 +223,45 @@ final class ContentEditorTm4eSupport {
     };
   }
 
-  /** Maps TM4E scope list to our TextAttribute (same palette as rule-based). 
*/
+  /**
+   * Maps TM4E scope list to our TextAttribute (same palette as rule-based). 
Attributes are cached
+   * per scope combination: a document produces the same handful of 
combinations over and over.
+   */
   private org.eclipse.jface.text.TextAttribute scopeToAttribute(List<String> 
scopes) {
-    RGB rgb = scopeToRgb(scopes);
-    Color color = new Color(display, rgb);
-    return new org.eclipse.jface.text.TextAttribute(color);
+    String scope = scopes == null ? "" : String.join(" ", scopes);
+    return attributeCache.computeIfAbsent(
+        scope,
+        key -> {
+          Color color = colorCache.computeIfAbsent(scopeToRgb(key), rgb -> new 
Color(display, rgb));
+          return new org.eclipse.jface.text.TextAttribute(color, null, 
scopeToFontStyle(key));
+        });
   }
 
-  private RGB scopeToRgb(List<String> scopes) {
-    if (scopes == null || scopes.isEmpty()) {
+  /**
+   * Font style (bold, italic, underline, strike-through) for the given scope. 
Only Markdown markup
+   * scopes use anything but the plain style today.
+   */
+  private static int scopeToFontStyle(String scope) {
+    int style = SWT.NORMAL;
+    if (scope.contains("markup.heading") || scope.contains("markup.bold")) {
+      style |= SWT.BOLD;
+    }
+    if (scope.contains(".italic") || scope.contains("markup.quote")) {
+      style |= SWT.ITALIC;
+    }
+    if (scope.contains("markup.underline")) {
+      style |= org.eclipse.jface.text.TextAttribute.UNDERLINE;
+    }
+    if (scope.contains("markup.strikethrough")) {
+      style |= org.eclipse.jface.text.TextAttribute.STRIKETHROUGH;
+    }
+    return style;
+  }
+
+  private RGB scopeToRgb(String scope) {
+    if (scope.isEmpty()) {
       return isDark() ? D_DEFAULT : L_DEFAULT;
     }
-    String scope = String.join(" ", scopes);
     boolean dark = isDark();
 
     if (TRACE_SCOPES) {
@@ -239,6 +273,32 @@ final class ContentEditorTm4eSupport {
       return dark ? D_COMMENT : L_COMMENT;
     }
 
+    // Markdown markup. Checked before the generic rules below because the 
inner scopes are named
+    // after what they mean in a document (heading, quote, raw, ...) rather 
than after a token type.
+    if (scope.contains("markup.heading")) {
+      return dark ? D_KEYWORD : L_JSON_KEY;
+    }
+    if (scope.contains("markup.underline.link")) {
+      return dark ? D_JSON_KEY : L_KEYWORD;
+    }
+    if (scope.contains("markup.quote")
+        || scope.contains("meta.separator")
+        || scope.contains("punctuation.definition.table")) {
+      return dark ? D_COMMENT : L_COMMENT;
+    }
+    if (scope.contains("fenced_code.block.language")
+        || scope.contains("punctuation.definition.list")) {
+      return dark ? D_KEYWORD : L_KEYWORD;
+    }
+    if (scope.contains("markup.inline.raw") || 
scope.contains("markup.fenced_code")) {
+      return dark ? D_STRING : L_STRING;
+    }
+    if (scope.contains("markup.bold")
+        || scope.contains("markup.italic")
+        || scope.contains("markup.strikethrough")) {
+      return dark ? D_KEYWORD : L_KEYWORD;
+    }
+
     // JSON keys: must be checked before "string" - VS Code uses 
"support.type.property-name.json"
     if (scope.contains("support.type.property-name") || 
scope.contains("property-name")) {
       return dark ? D_JSON_KEY : L_JSON_KEY;
@@ -261,7 +321,7 @@ final class ContentEditorTm4eSupport {
     if (scope.contains("constant.language")) {
       return dark ? D_CONSTANT : L_CONSTANT;
     }
-    if (scope.contains("constant.other")) {
+    if (scope.contains("constant.other") || 
scope.contains("constant.character")) {
       return dark ? D_CONSTANT : L_CONSTANT;
     }
 
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
index bda9e9a047..f300c90a88 100644
--- 
a/rcp/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
+++ 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
@@ -22,8 +22,8 @@ import org.eclipse.swt.widgets.Display;
 
 /**
  * Builds a {@link SourceViewerConfiguration} for the content editor. Uses 
TM4E with TextMate
- * grammars for syntax highlighting of JSON, XML, SQL, Python, and YAML; other 
languages get a plain
- * config.
+ * grammars for syntax highlighting of JSON, XML, SQL, Python, YAML, and 
Markdown; other languages
+ * get a plain config.
  */
 public final class RuleBasedSourceViewerConfiguration {
 
@@ -31,8 +31,8 @@ public final class RuleBasedSourceViewerConfiguration {
 
   /**
    * Creates a configuration for the given language. Uses TM4E when a grammar 
is available (json,
-   * xml, sql, python, yaml, shell, bat); otherwise returns a plain 
configuration with no syntax
-   * highlighting.
+   * xml, sql, python, yaml, shell, bat, markdown); otherwise returns a plain 
configuration with no
+   * syntax highlighting.
    *
    * @param languageId language id (e.g. "json", "xml", "sql", "yaml"), or 
null for plain text
    * @return configuration, never null
diff --git 
a/rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/markdown.json 
b/rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/markdown.json
new file mode 100644
index 0000000000..4f19614cf4
--- /dev/null
+++ b/rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/markdown.json
@@ -0,0 +1,178 @@
+{
+       "information_for_contributors": [
+               "This grammar was written for Apache Hop, it is not converted 
from an existing TextMate bundle.",
+               "It is intentionally basic: headings, emphasis, code, links, 
lists, quotes, tables and separators.",
+               "Fenced code blocks are not highlighted with the grammar of the 
embedded language."
+       ],
+       "name": "Markdown",
+       "scopeName": "text.html.markdown",
+       "fileTypes": ["md", "markdown"],
+       "patterns": [
+               { "include": "#fenced_code_block" },
+               { "include": "#html_comment" },
+               { "include": "#heading" },
+               { "include": "#setext_heading" },
+               { "include": "#separator" },
+               { "include": "#blockquote" },
+               { "include": "#task_list_item" },
+               { "include": "#list_item" },
+               { "include": "#table_delimiter_row" },
+               { "include": "#link_definition" },
+               { "include": "#inline" }
+       ],
+       "repository": {
+               "fenced_code_block": {
+                       "name": "markup.fenced_code.block.markdown",
+                       "begin": "^[ ]{0,3}(`{3,}|~{3,})[ 
\\t]*([a-zA-Z0-9_+#.-]*)[ \\t]*$",
+                       "beginCaptures": {
+                               "1": { "name": 
"punctuation.definition.fenced_code.markdown" },
+                               "2": { "name": 
"fenced_code.block.language.markdown" }
+                       },
+                       "end": "^[ ]{0,3}(\\1)[ \\t]*$",
+                       "endCaptures": {
+                               "1": { "name": 
"punctuation.definition.fenced_code.markdown" }
+                       }
+               },
+               "html_comment": {
+                       "name": "comment.block.html.markdown",
+                       "begin": "<!--",
+                       "end": "-->"
+               },
+               "heading": {
+                       "name": "markup.heading.markdown",
+                       "match": "^[ ]{0,3}(#{1,6})[ \\t]+(.*?)[ \\t]*(#*)[ 
\\t]*$",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.heading.markdown" },
+                               "3": { "name": 
"punctuation.definition.heading.markdown" }
+                       }
+               },
+               "setext_heading": {
+                       "name": "markup.heading.setext.markdown",
+                       "match": "^[ ]{0,3}=+[ \\t]*$"
+               },
+               "separator": {
+                       "name": "meta.separator.markdown",
+                       "match": "^[ ]{0,3}((-[ \\t]*){3,}|(\\*[ \\t]*){3,}|(_[ 
\\t]*){3,})$"
+               },
+               "blockquote": {
+                       "name": "markup.quote.markdown",
+                       "match": "^[ ]{0,3}(>[ \\t]?)(.*)$",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.quote.begin.markdown" },
+                               "2": { "patterns": [{ "include": "#inline" }] }
+                       }
+               },
+               "task_list_item": {
+                       "match": "^[ \\t]*([*+-])[ \\t]+(\\[[ xX]\\])[ \\t]+",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.list.begin.markdown" },
+                               "2": { "name": "constant.language.markdown" }
+                       }
+               },
+               "list_item": {
+                       "match": "^[ \\t]*([*+-]|[0-9]{1,9}[.)])[ \\t]+",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.list.begin.markdown" }
+                       }
+               },
+               "table_delimiter_row": {
+                       "name": "punctuation.definition.table.markdown",
+                       "match": "^[ \\t]*\\|?([ \\t]*:?-+:?[ \\t]*\\|)+[ 
\\t]*:?-*:?[ \\t]*$"
+               },
+               "link_definition": {
+                       "match": "^[ ]{0,3}(\\[)([^\\]]+)(\\]:)[ \\t]*(\\S+)",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.link.markdown" },
+                               "2": { "name": 
"string.other.link.title.markdown" },
+                               "3": { "name": 
"punctuation.definition.link.markdown" },
+                               "4": { "name": "markup.underline.link.markdown" 
}
+                       }
+               },
+               "inline": {
+                       "patterns": [
+                               { "include": "#escape" },
+                               { "include": "#raw" },
+                               { "include": "#image" },
+                               { "include": "#link" },
+                               { "include": "#autolink" },
+                               { "include": "#bare_link" },
+                               { "include": "#bold_italic" },
+                               { "include": "#bold" },
+                               { "include": "#italic_asterisk" },
+                               { "include": "#italic_underscore" },
+                               { "include": "#strikethrough" },
+                               { "include": "#html_tag" }
+                       ]
+               },
+               "escape": {
+                       "name": "constant.character.escape.markdown",
+                       "match": "\\\\[\\\\`*_{}\\[\\]()#+\\-.!>~|]"
+               },
+               "raw": {
+                       "name": "markup.inline.raw.string.markdown",
+                       "match": "(`+)([^`]*)(\\1)",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.raw.markdown" },
+                               "3": { "name": 
"punctuation.definition.raw.markdown" }
+                       }
+               },
+               "image": {
+                       "match": "(!\\[)([^\\]]*)(\\])(\\()([^)]*)(\\))",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.link.markdown" },
+                               "2": { "name": 
"string.other.link.description.markdown" },
+                               "3": { "name": 
"punctuation.definition.link.markdown" },
+                               "4": { "name": 
"punctuation.definition.link.markdown" },
+                               "5": { "name": "markup.underline.link.markdown" 
},
+                               "6": { "name": 
"punctuation.definition.link.markdown" }
+                       }
+               },
+               "link": {
+                       "match": "(\\[)([^\\]]*)(\\])(\\()([^)]*)(\\))",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.link.markdown" },
+                               "2": { "name": 
"string.other.link.title.markdown" },
+                               "3": { "name": 
"punctuation.definition.link.markdown" },
+                               "4": { "name": 
"punctuation.definition.link.markdown" },
+                               "5": { "name": "markup.underline.link.markdown" 
},
+                               "6": { "name": 
"punctuation.definition.link.markdown" }
+                       }
+               },
+               "autolink": {
+                       "match": "(<)((?:https?|ftp|mailto):[^>\\s]+)(>)",
+                       "captures": {
+                               "1": { "name": 
"punctuation.definition.link.markdown" },
+                               "2": { "name": "markup.underline.link.markdown" 
},
+                               "3": { "name": 
"punctuation.definition.link.markdown" }
+                       }
+               },
+               "bare_link": {
+                       "name": "markup.underline.link.markdown",
+                       "match": "\\b(?:https?|ftp)://[^\\s<>()\\[\\]\"']+"
+               },
+               "bold_italic": {
+                       "name": "markup.bold.italic.markdown",
+                       "match": "(\\*\\*\\*|___)(?=\\S)(.*?\\S)\\1"
+               },
+               "bold": {
+                       "name": "markup.bold.markdown",
+                       "match": "(\\*\\*|__)(?=\\S)(.*?\\S)\\1"
+               },
+               "italic_asterisk": {
+                       "name": "markup.italic.markdown",
+                       "match": "(\\*)(?=[^\\s*])([^*]*[^\\s*])\\1"
+               },
+               "italic_underscore": {
+                       "name": "markup.italic.markdown",
+                       "match": 
"(?<![\\w\\\\_])(_)(?=[^\\s_])([^_]*[^\\s_])\\1(?!\\w)"
+               },
+               "strikethrough": {
+                       "name": "markup.strikethrough.markdown",
+                       "match": "(~~)(?=\\S)(.*?\\S)\\1"
+               },
+               "html_tag": {
+                       "name": "meta.tag.markdown",
+                       "match": "</?[a-zA-Z][a-zA-Z0-9-]*(?:\\s[^<>]*)?/?>"
+               }
+       }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
index 37e9d499a1..9a8a8d267b 100644
--- 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupportTest.java
@@ -27,10 +27,12 @@ import java.io.InputStreamReader;
 import java.io.Reader;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.tm4e.core.grammar.IGrammar;
+import org.eclipse.tm4e.core.grammar.IStateStack;
 import org.eclipse.tm4e.core.grammar.ITokenizeLineResult;
 import org.eclipse.tm4e.core.registry.IGrammarSource;
 import org.eclipse.tm4e.core.registry.IRegistryOptions;
@@ -159,6 +161,105 @@ class ContentEditorTm4eSupportTest {
     assertTrue(result.getTokens().length > 0, "batch line should produce 
tokens");
   }
 
+  @Test
+  void scopeForLanguage_markdownAliases_returnTextHtmlMarkdown() {
+    assertEquals("text.html.markdown", 
ContentEditorTm4eSupport.scopeForLanguage("markdown"));
+    assertEquals("text.html.markdown", 
ContentEditorTm4eSupport.scopeForLanguage("md"));
+  }
+
+  @Test
+  void markdownGrammarResourceIsOnClasspath() throws Exception {
+    try (InputStream in =
+        
ContentEditorTm4eSupport.class.getResourceAsStream("grammars/markdown.json")) {
+      assertNotNull(in, "grammars/markdown.json should be on the classpath");
+      assertTrue(in.read() >= 0, "markdown.json should not be empty");
+    }
+  }
+
+  @Test
+  void markdownGrammarScopesBlockElements() throws Exception {
+    IGrammar grammar = loadGrammar("text.html.markdown", "markdown.json");
+    assertNotNull(grammar, "TM4E should load the markdown grammar");
+
+    assertTrue(
+        scopesOf(grammar, "# Title").contains("markup.heading.markdown"),
+        "a heading line should be scoped as a heading");
+    assertTrue(
+        scopesOf(grammar, "> quoted").contains("markup.quote.markdown"),
+        "a blockquote line should be scoped as a quote");
+    assertTrue(
+        scopesOf(grammar, "- 
item").contains("punctuation.definition.list.begin.markdown"),
+        "a list bullet should be scoped as a list");
+    assertTrue(
+        scopesOf(grammar, "---").contains("meta.separator.markdown"),
+        "a thematic break should be scoped as a separator");
+    assertTrue(
+        scopesOf(grammar, "| --- | :-: 
|").contains("punctuation.definition.table.markdown"),
+        "a table delimiter row should be scoped as a table");
+  }
+
+  @Test
+  void markdownGrammarScopesInlineElements() throws Exception {
+    IGrammar grammar = loadGrammar("text.html.markdown", "markdown.json");
+
+    assertTrue(
+        scopesOf(grammar, "some **bold** 
text").contains("markup.bold.markdown"),
+        "**...** should be scoped as bold");
+    assertTrue(
+        scopesOf(grammar, "some *italic* 
text").contains("markup.italic.markdown"),
+        "*...* should be scoped as italic");
+    assertTrue(
+        scopesOf(grammar, "snake_case_word").stream().noneMatch(s -> 
s.contains("markup.italic")),
+        "underscores inside a word should not turn into italics");
+    assertTrue(
+        scopesOf(grammar, "an `inline` 
snippet").contains("markup.inline.raw.string.markdown"),
+        "`...` should be scoped as inline raw");
+    assertTrue(
+        scopesOf(grammar, "see [Hop](https://hop.apache.org)")
+            .contains("markup.underline.link.markdown"),
+        "a link target should be scoped as a link");
+  }
+
+  @Test
+  void markdownGrammarKeepsFencedCodeBlockStateAcrossLines() throws Exception {
+    IGrammar grammar = loadGrammar("text.html.markdown", "markdown.json");
+
+    IStateStack state = null;
+    List<String> insideScopes = List.of();
+    List<String> afterScopes = List.of();
+    for (String line : List.of("```sql", "SELECT 1", "```", "# after")) {
+      ITokenizeLineResult<org.eclipse.tm4e.core.grammar.IToken[]> result =
+          grammar.tokenizeLine(line, state, null);
+      state = result.getRuleStack();
+      if ("SELECT 1".equals(line)) {
+        insideScopes = scopesOf(result);
+      } else if ("# after".equals(line)) {
+        afterScopes = scopesOf(result);
+      }
+    }
+
+    assertTrue(
+        insideScopes.contains("markup.fenced_code.block.markdown"),
+        "lines between fences should stay inside the fenced code block");
+    assertTrue(
+        afterScopes.contains("markup.heading.markdown"),
+        "the closing fence should end the block so following markup is scoped 
again");
+  }
+
+  /** All scopes produced for a single line, without carrying over any state. 
*/
+  private static List<String> scopesOf(IGrammar grammar, String line) {
+    return scopesOf(grammar.tokenizeLine(line, null, null));
+  }
+
+  private static List<String> scopesOf(
+      ITokenizeLineResult<org.eclipse.tm4e.core.grammar.IToken[]> result) {
+    List<String> scopes = new ArrayList<>();
+    for (org.eclipse.tm4e.core.grammar.IToken token : result.getTokens()) {
+      scopes.addAll(token.getScopes());
+    }
+    return scopes;
+  }
+
   @Test
   void scopeForLanguage_yaml_returnsSourceYaml() {
     assertEquals("source.yaml", 
ContentEditorTm4eSupport.scopeForLanguage("yaml"));

Reply via email to