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"));