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 9527b350ff crispy treeview images in hop web, fixes #8434 (#8436)
9527b350ff is described below

commit 9527b350fff47e7537ead621d67aba1bf50afdea
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Sep 17 12:55:22 2026 +0200

    crispy treeview images in hop web, fixes #8434 (#8436)
---
 .../java/org/apache/hop/core/svg/SvgSizing.java    | 108 ++++++++++
 .../org/apache/hop/core/svg/SvgSizingTest.java     | 130 ++++++++++++
 .../ROOT/assets/images/icons/folder-add.svg        |  14 +-
 .../modules/ROOT/assets/images/icons/folder.svg    |  18 +-
 .../org/apache/hop/git/GitCommitPerspective.java   |   6 +-
 .../org/apache/hop/lint/LintStatusFilePainter.java |  21 +-
 .../actions/getpop/SelectFolderDialog.java         |   2 +
 .../UserDefinedJavaClassDialog.java                |   8 +-
 .../transforms/javascript/ScriptValuesDialog.java  |   6 +-
 .../pipeline/transforms/script/ScriptDialog.java   |   6 +-
 .../hop/ui/core/widget/svg/SvgImageFacadeImpl.java | 173 ++++++++++++++++
 .../ui/core/widget/svg/SvgImageFacadeImplTest.java | 217 +++++++++++++++++++++
 .../hop/ui/core/widget/svg/SvgImageFacadeImpl.java |  45 +++++
 .../hop/ui/core/widget/FolderTreeIconsTest.java    | 165 ++++++++++++++++
 .../org/apache/hop/core/SwtUniversalImage.java     |  26 ++-
 .../org/apache/hop/core/SwtUniversalImageSvg.java  |  28 +++
 .../database/dialog/DatabaseExplorerDialog.java    |  12 +-
 .../org/apache/hop/ui/core/gui/GuiResource.java    |  28 ++-
 .../apache/hop/ui/core/vfs/HopVfsFileDialog.java   |   4 +-
 .../org/apache/hop/ui/core/widget/FileTree.java    |   3 +-
 .../apache/hop/ui/core/widget/FolderTreeIcons.java |  70 +++++++
 .../org/apache/hop/ui/core/widget/TreeMemory.java  |   2 +-
 .../hop/ui/core/widget/svg/SvgImageFacade.java     |  88 +++++++++
 .../hop/ui/hopgui/palette/GraphPaletteTree.java    |   4 +-
 .../perspective/database/DatabaseWorkbench.java    |  15 +-
 .../perspective/explorer/ExplorerPerspective.java  |  33 ++--
 .../perspective/metadata/MetadataPerspective.java  |  13 +-
 ui/src/main/resources/ui/images/folder-add.svg     |   6 +-
 ui/src/main/resources/ui/images/folder-open.svg    |   4 +
 ui/src/main/resources/ui/images/folder.svg         |  10 +-
 30 files changed, 1174 insertions(+), 91 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/svg/SvgSizing.java 
b/core/src/main/java/org/apache/hop/core/svg/SvgSizing.java
new file mode 100644
index 0000000000..06b3d6f39f
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/svg/SvgSizing.java
@@ -0,0 +1,108 @@
+/*
+ * 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.core.svg;
+
+import java.math.BigDecimal;
+import java.util.Locale;
+import org.apache.batik.anim.dom.SVGDOMImplementation;
+import org.apache.batik.dom.util.DOMUtilities;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.xml.XmlHandler;
+import org.w3c.dom.Attr;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+
+/** Serialises an SVG so that a browser draws it at an exact pixel size. */
+public final class SvgSizing {
+
+  private SvgSizing() {}
+
+  /**
+   * XML of a copy of {@code document} whose root asks for {@code width} x 
{@code height} CSS
+   * pixels. Most Hop icons carry {@code width}/{@code height} but no {@code 
viewBox}; without one a
+   * browser would clip the drawing to the new size instead of scaling it, so 
the intrinsic size
+   * becomes the viewBox. The aspect ratio is not preserved, which is what the 
raster renderer in
+   * {@code SwingUniversalImageSvg#render} does as well.
+   *
+   * <p>The copy is served from the web application's own origin, and a 
project SVG file is not
+   * necessarily an icon someone vetted, so script elements, event-handler 
attributes, {@code
+   * foreignObject} and script URLs are dropped: nothing in a picture may run.
+   *
+   * @param document the SVG, left untouched
+   * @param intrinsicWidth the width the drawing was authored for, in user 
units
+   * @param intrinsicHeight the height the drawing was authored for, in user 
units
+   * @param width the requested width in CSS pixels
+   * @param height the requested height in CSS pixels
+   * @return the sized SVG as XML, with declaration
+   * @throws HopException when the copy cannot be serialised
+   */
+  public static String toXml(
+      Document document, double intrinsicWidth, double intrinsicHeight, int 
width, int height)
+      throws HopException {
+    Document sized =
+        DOMUtilities.deepCloneDocument(document, 
SVGDOMImplementation.getDOMImplementation());
+    Element root = sized.getDocumentElement();
+    if (!root.hasAttribute("viewBox")) {
+      root.setAttribute("viewBox", "0 0 " + plain(intrinsicWidth) + " " + 
plain(intrinsicHeight));
+    }
+    root.setAttribute("width", String.valueOf(width));
+    root.setAttribute("height", String.valueOf(height));
+    root.setAttribute("preserveAspectRatio", "none");
+    stripActiveContent(root);
+    return XmlHandler.getXmlString(sized, false, false);
+  }
+
+  /** Removes everything a browser could execute from {@code element} and its 
descendants. */
+  static void stripActiveContent(Element element) {
+    Node child = element.getFirstChild();
+    while (child != null) {
+      Node next = child.getNextSibling();
+      if (child instanceof Element childElement) {
+        String name = childElement.getLocalName();
+        if ("script".equalsIgnoreCase(name) || 
"foreignObject".equalsIgnoreCase(name)) {
+          element.removeChild(child);
+        } else {
+          stripActiveContent(childElement);
+        }
+      }
+      child = next;
+    }
+    NamedNodeMap attributes = element.getAttributes();
+    for (int i = attributes.getLength() - 1; i >= 0; i--) {
+      Attr attribute = (Attr) attributes.item(i);
+      String name =
+          attribute.getLocalName() == null ? attribute.getName() : 
attribute.getLocalName();
+      String value = attribute.getValue().trim().toLowerCase(Locale.ROOT);
+      boolean handler = name.regionMatches(true, 0, "on", 0, 2);
+      boolean scriptUrl =
+          "href".equalsIgnoreCase(name)
+              && (value.startsWith("javascript:")
+                  || (value.startsWith("data:") && 
!value.startsWith("data:image/")));
+      if (handler || scriptUrl) {
+        element.removeAttributeNode(attribute);
+      }
+    }
+  }
+
+  /** {@code 24.0} as {@code 24}, {@code 23.5} as {@code 23.5}: no exponent, 
no trailing zeros. */
+  private static String plain(double value) {
+    return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
+  }
+}
diff --git a/core/src/test/java/org/apache/hop/core/svg/SvgSizingTest.java 
b/core/src/test/java/org/apache/hop/core/svg/SvgSizingTest.java
new file mode 100644
index 0000000000..a6b787762d
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/svg/SvgSizingTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.core.svg;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
+import org.apache.batik.util.XMLResourceDescriptor;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+class SvgSizingTest {
+
+  private static Document parse(String xml) throws Exception {
+    SAXSVGDocumentFactory factory =
+        new 
SAXSVGDocumentFactory(XMLResourceDescriptor.getXMLParserClassName());
+    return factory.createDocument(
+        "test.svg", new 
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+  }
+
+  private static Element rootOf(String xml) throws Exception {
+    return parse(xml).getDocumentElement();
+  }
+
+  @Test
+  void addsViewBoxFromIntrinsicSizeWhenMissing() throws Exception {
+    Document icon =
+        parse(
+            "<svg xmlns=\"http://www.w3.org/2000/svg\"; width=\"24\" 
height=\"24\">"
+                + "<path fill=\"#0e3a5a\" d=\"M2 2h20v20H2z\"/></svg>");
+
+    Element root = rootOf(SvgSizing.toXml(icon, 24, 24, 16, 16));
+
+    assertEquals("0 0 24 24", root.getAttribute("viewBox"));
+    assertEquals("16", root.getAttribute("width"));
+    assertEquals("16", root.getAttribute("height"));
+    assertEquals("none", root.getAttribute("preserveAspectRatio"));
+    assertEquals(1, root.getElementsByTagName("path").getLength(), "content is 
kept");
+  }
+
+  @Test
+  void keepsAnExistingViewBox() throws Exception {
+    Document icon =
+        parse(
+            "<svg xmlns=\"http://www.w3.org/2000/svg\"; width=\"32\" 
height=\"32\""
+                + " viewBox=\"4 4 24 24\"><rect width=\"1\" 
height=\"1\"/></svg>");
+
+    Element root = rootOf(SvgSizing.toXml(icon, 32, 32, 20, 20));
+
+    assertEquals("4 4 24 24", root.getAttribute("viewBox"));
+    assertEquals("20", root.getAttribute("width"));
+  }
+
+  @Test
+  void fractionalIntrinsicSizeIsWrittenPlainly() throws Exception {
+    Document icon = parse("<svg 
xmlns=\"http://www.w3.org/2000/svg\";><rect/></svg>");
+
+    Element root = rootOf(SvgSizing.toXml(icon, 23.5, 1000000, 16, 16));
+
+    assertEquals("0 0 23.5 1000000", root.getAttribute("viewBox"));
+  }
+
+  @Test
+  void nothingExecutableSurvivesTheCopy() throws Exception {
+    Document icon =
+        parse(
+            "<svg xmlns=\"http://www.w3.org/2000/svg\"; 
xmlns:xlink=\"http://www.w3.org/1999/xlink\"";
+                + " width=\"24\" height=\"24\" onload=\"alert(1)\">"
+                + "<script>alert(2)</script>"
+                + "<g onclick=\"alert(3)\"><foreignObject><div 
xmlns=\"http://www.w3.org/1999/xhtml\";>x</div></foreignObject>"
+                + "<a xlink:href=\"javascript:alert(4)\"><rect width=\"1\" 
height=\"1\"/></a>"
+                + "<a href=\"data:text/html,x\"/>"
+                + "<use xlink:href=\"#shape\"/>"
+                + "<image href=\"data:image/png;base64,AA==\"/>"
+                + "</g></svg>");
+
+    String xml = SvgSizing.toXml(icon, 24, 24, 16, 16);
+    Element root = rootOf(xml);
+
+    assertFalse(xml.toLowerCase().contains("alert("), xml);
+    assertEquals(0, root.getElementsByTagName("script").getLength());
+    assertEquals(0, root.getElementsByTagName("foreignObject").getLength());
+    assertFalse(root.hasAttribute("onload"));
+    Element g = (Element) root.getElementsByTagName("g").item(0);
+    assertFalse(g.hasAttribute("onclick"));
+    assertEquals(1, root.getElementsByTagName("rect").getLength(), "content is 
kept");
+    assertEquals(
+        "#shape",
+        ((Element) root.getElementsByTagName("use").item(0))
+            .getAttributeNS("http://www.w3.org/1999/xlink";, "href"),
+        "fragment links are fine");
+    assertTrue(
+        ((Element) root.getElementsByTagName("image").item(0))
+            .getAttribute("href")
+            .startsWith("data:image/"),
+        "embedded images are fine");
+  }
+
+  @Test
+  void sourceDocumentIsNotModified() throws Exception {
+    Document icon = parse("<svg xmlns=\"http://www.w3.org/2000/svg\"; 
width=\"24\" height=\"24\"/>");
+
+    String xml = SvgSizing.toXml(icon, 24, 24, 16, 16);
+
+    assertTrue(xml.startsWith("<?xml"), "declaration kept for the resource 
file");
+    Element original = icon.getDocumentElement();
+    assertEquals("24", original.getAttribute("width"));
+    assertFalse(original.hasAttribute("viewBox"));
+  }
+}
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder-add.svg 
b/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder-add.svg
index 5d3294e6e6..eaea2a5969 100644
--- a/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder-add.svg
+++ b/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder-add.svg
@@ -1,12 +1,4 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg
-        xmlns="http://www.w3.org/2000/svg";
-        version="1.1"
-        width="24"
-        height="24"
-        viewBox="0 0 24 24"
->
-    <path
-            d="M 9.8,3 12,5.2500001 h 8.8 A 2.2,2.2500001 0 0 1 23,7.5000002 V 
18.750001 A 2.2,2.2500001 0 0 1 20.8,21 H 3.2 C 1.979,21 1,19.9875 1,18.750001 
V 5.2500001 C 1,4.0012501 1.979,3 3.2,3 h 6.6 m 5.5,5.6250003 V 12 H 12 v 
2.249999 h 3.3 V 17.625 h 2.2 v -3.375001 h 3.3 V 12 H 17.5 V 8.6250003 Z"
-            style="fill:#0e3a5a;fill-opacity:1;stroke-width:1.11245"/>
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+    <path fill="#0e3a5a" d="M20 6h-8l-2-2H4c-1.11 0-1.99.89-1.99 2L2 18c0 
1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-1 
8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z"/>
 </svg>
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder.svg 
b/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder.svg
index ffc3075302..a176cd8dfc 100644
--- a/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder.svg
+++ b/docs/hop-user-manual/modules/ROOT/assets/images/icons/folder.svg
@@ -1,16 +1,4 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg
-        xmlns="http://www.w3.org/2000/svg";
-        height="24"
-        viewBox="0 0 24 24"
-        width="24"
-        version="1.1"
->
-    <path
-            d="M0 0h24v24H0z"
-            fill="none"
-    />
-    <path
-            d="m 20.799999,5.2499999 h -8.8 L 9.8,3 H 3.2 C 1.99,3 
1.0110001,4.0124999 1.0110001,5.2499999 L 1,18.749999 C 1,19.987499 1.99,21 
3.2,21 H 20.799999 C 22.01,21 23,19.987499 23,18.749999 V 7.5000001 C 23,6.2625 
22.01,5.2499999 20.799999,5.2499999 Z m 0,13.4999991 H 3.2 V 7.5000001 h 
17.599999 z"
-            style="fill:#0e3a5a;fill-opacity:1;stroke-width:1.11243"/>
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+    <path fill="#0e3a5a" d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 
2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
 </svg>
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
index 6202c45141..5a83685ab3 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
@@ -51,6 +51,7 @@ import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
@@ -245,6 +246,7 @@ public class GitCommitPerspective implements 
IHopPerspective {
     PropsUi.setLook(wToolBar, Props.WIDGET_STYLE_TOOLBAR);
 
     wTree = new Tree(composite, SWT.CHECK | SWT.V_SCROLL | SWT.H_SCROLL);
+    FolderTreeIcons.install(wTree);
     wTree.setHeaderVisible(false);
     
wTree.setLayoutData(FormDataBuilder.builder().fullWidth().top(wToolBar).bottom().result());
     wTree.addListener(SWT.Selection, this::onSelection);
@@ -464,7 +466,7 @@ public class GitCommitPerspective implements 
IHopPerspective {
           fileType.openFile(hopGui, path, hopGui.getVariables());
           perspective.activate();
         } else {
-          item.setExpanded(!item.getExpanded());
+          FolderTreeIcons.setExpanded(item, !item.getExpanded());
         }
       }
     } catch (Exception e) {
@@ -1215,7 +1217,7 @@ public class GitCommitPerspective implements 
IHopPerspective {
         setRootItemLabel(untrackedRootItem, UNTRACKED_LABEL);
 
         for (TreeItem rootItem : wTree.getItems()) {
-          rootItem.setExpanded(true);
+          FolderTreeIcons.setExpanded(rootItem, true);
         }
 
         // Offer all the work in progress for the next commit
diff --git 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
index e018463d2c..8c7d557718 100644
--- 
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
+++ 
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
@@ -25,6 +25,7 @@ import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.svg.SvgImageFacade;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.SessionDisplay;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
@@ -34,6 +35,7 @@ import org.eclipse.swt.graphics.Image;
 import org.eclipse.swt.graphics.ImageData;
 import org.eclipse.swt.graphics.PaletteData;
 import org.eclipse.swt.graphics.RGB;
+import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Tree;
 import org.eclipse.swt.widgets.TreeItem;
@@ -287,7 +289,7 @@ public class LintStatusFilePainter implements 
IExplorerFilePaintListener {
       }
 
       boolean noBase = base == null || base.isDisposed();
-      org.eclipse.swt.graphics.Rectangle bounds = noBase ? null : 
base.getBounds();
+      Rectangle bounds = noBase ? null : base.getBounds();
       // Rasterize the badge at the size it will occupy on this icon, so 
nothing is resampled.
       Image lintIcon =
           badgeIcon(
@@ -349,18 +351,27 @@ public class LintStatusFilePainter implements 
IExplorerFilePaintListener {
    * NullPointerException, and disposing it then fails with "A factory-created 
resource cannot be
    * disposed", which is the exception that reaches the log. Working on the 
{@link ImageData} of
    * both icons needs no drawing surface at all, so Hop Web gets the same 
badges as the desktop.
+   *
+   * <p>On Hop Web the icons are vectors the browser renders, so the badge is 
first tried as a
+   * vector overlay ({@link SvgImageFacade#overlay}) and stays sharp on a 
high-DPI screen; pixel
+   * compositing is the fallback for anything that is not vector-backed.
    */
   private Image createCompositeIcon(Image originalIcon, Image lintIcon) {
     try {
       if (display == null || display.isDisposed()) {
         return null;
       }
-      ImageData baseData = SwtUniversalImage.getImageDataAtZoom(originalIcon, 
100);
-      if (baseData == null) {
+      Rectangle bounds = originalIcon.getBounds();
+      int badgeSize = badgeSizeFor(bounds.width, bounds.height);
+      if (badgeSize <= 0) {
         return null;
       }
-      int badgeSize = badgeSizeFor(baseData.width, baseData.height);
-      if (badgeSize <= 0) {
+      Image vector = SvgImageFacade.overlay(display, originalIcon, lintIcon, 
badgeSize, 1);
+      if (vector != null) {
+        return vector;
+      }
+      ImageData baseData = SwtUniversalImage.getImageDataAtZoom(originalIcon, 
100);
+      if (baseData == null) {
         return null;
       }
       // Composited again for every zoom the platform asks for, out of what 
both icons themselves
diff --git 
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/workflow/actions/getpop/SelectFolderDialog.java
 
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/workflow/actions/getpop/SelectFolderDialog.java
index 8426581c47..a57c8feb52 100644
--- 
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/workflow/actions/getpop/SelectFolderDialog.java
+++ 
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/workflow/actions/getpop/SelectFolderDialog.java
@@ -24,6 +24,7 @@ import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
@@ -74,6 +75,7 @@ public class SelectFolderDialog extends Dialog {
 
     // Tree
     wTree = new HopTree(shell, SWT.SINGLE | SWT.BORDER);
+    FolderTreeIcons.install(wTree);
     PropsUi.setLook(wTree);
     // Double click in tree: select the value
     wTree.addListener(SWT.DefaultSelection, e -> ok());
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
index bb58a9bc13..2e922c1169 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
@@ -59,6 +59,7 @@ import org.apache.hop.ui.core.dialog.MessageDialogWithToggle;
 import org.apache.hop.ui.core.dialog.PreviewRowsDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.core.widget.JavaStyledTextComp;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
@@ -286,6 +287,7 @@ public class UserDefinedJavaClassDialog extends 
BaseTransformDialog {
 
     // Tree View Test
     wTree = new HopTree(wTop, SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL);
+    FolderTreeIcons.install(wTree);
     PropsUi.setLook(wTree);
     FormData fdlTree = new FormData();
     fdlTree.left = new FormAttachment(0, 0);
@@ -458,21 +460,21 @@ public class UserDefinedJavaClassDialog extends 
BaseTransformDialog {
     itemWaitFieldsIn.setText(
         BaseMessages.getString(PKG, 
CONST_USER_DEFINED_JAVA_CLASS_DIALOG_GETTING_FIELDS_LABEL));
     itemWaitFieldsIn.setForeground(guiResource.getColorDirectory());
-    itemInput.setExpanded(true);
+    FolderTreeIcons.setExpanded(itemInput, true);
 
     // Display waiting message for info
     TreeItem itemWaitFieldsInfo = new TreeItem(itemInfo, SWT.NULL);
     itemWaitFieldsInfo.setText(
         BaseMessages.getString(PKG, 
CONST_USER_DEFINED_JAVA_CLASS_DIALOG_GETTING_FIELDS_LABEL));
     itemWaitFieldsInfo.setForeground(guiResource.getColorDirectory());
-    itemInfo.setExpanded(true);
+    FolderTreeIcons.setExpanded(itemInfo, true);
 
     // Display waiting message for output
     TreeItem itemWaitFieldsOut = new TreeItem(itemOutput, SWT.NULL);
     itemWaitFieldsOut.setText(
         BaseMessages.getString(PKG, 
CONST_USER_DEFINED_JAVA_CLASS_DIALOG_GETTING_FIELDS_LABEL));
     itemWaitFieldsOut.setForeground(guiResource.getColorDirectory());
-    itemOutput.setExpanded(true);
+    FolderTreeIcons.setExpanded(itemOutput, true);
 
     //
     // Search the fields in the background
diff --git 
a/plugins/transforms/javascript/src/main/java/org/apache/hop/pipeline/transforms/javascript/ScriptValuesDialog.java
 
b/plugins/transforms/javascript/src/main/java/org/apache/hop/pipeline/transforms/javascript/ScriptValuesDialog.java
index ebaf272c09..cc94066688 100644
--- 
a/plugins/transforms/javascript/src/main/java/org/apache/hop/pipeline/transforms/javascript/ScriptValuesDialog.java
+++ 
b/plugins/transforms/javascript/src/main/java/org/apache/hop/pipeline/transforms/javascript/ScriptValuesDialog.java
@@ -52,6 +52,7 @@ import org.apache.hop.ui.core.dialog.PreviewRowsDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.ComboVar;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.core.widget.JavaScriptStyledTextComp;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
@@ -251,6 +252,7 @@ public class ScriptValuesDialog extends BaseTransformDialog 
{
 
     // Tree View Test
     wTree = new HopTree(wTop, SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL);
+    FolderTreeIcons.install(wTree);
     PropsUi.setLook(wTree);
     FormData fdlTree = new FormData();
     fdlTree.left = new FormAttachment(0, 0);
@@ -498,14 +500,14 @@ public class ScriptValuesDialog extends 
BaseTransformDialog {
     itemWaitFieldsIn.setText(
         BaseMessages.getString(PKG, 
"ScriptValuesDialogMod.GettingFields.Label"));
     itemWaitFieldsIn.setForeground(guiresource.getColorDirectory());
-    iteminput.setExpanded(true);
+    FolderTreeIcons.setExpanded(iteminput, true);
 
     // Display waiting message for output
     TreeItem itemWaitFieldsOut = new TreeItem(itemoutput, SWT.NULL);
     itemWaitFieldsOut.setText(
         BaseMessages.getString(PKG, 
"ScriptValuesDialogMod.GettingFields.Label"));
     itemWaitFieldsOut.setForeground(guiresource.getColorDirectory());
-    itemoutput.setExpanded(true);
+    FolderTreeIcons.setExpanded(itemoutput, true);
 
     //
     // Search the fields in the background
diff --git 
a/plugins/transforms/script/src/main/java/org/apache/hop/pipeline/transforms/script/ScriptDialog.java
 
b/plugins/transforms/script/src/main/java/org/apache/hop/pipeline/transforms/script/ScriptDialog.java
index 18aae2ee7a..366a51a0e2 100644
--- 
a/plugins/transforms/script/src/main/java/org/apache/hop/pipeline/transforms/script/ScriptDialog.java
+++ 
b/plugins/transforms/script/src/main/java/org/apache/hop/pipeline/transforms/script/ScriptDialog.java
@@ -36,6 +36,7 @@ import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.core.widget.ScriptStyledTextComp;
 import org.apache.hop.ui.core.widget.StyledTextComp;
@@ -222,6 +223,7 @@ public class ScriptDialog extends BaseTransformDialog {
 
     // Tree View Test
     wTree = new HopTree(wTop, SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL);
+    FolderTreeIcons.install(wTree);
     PropsUi.setLook(wTree);
     FormData fdlTree = new FormData();
     fdlTree.left = new FormAttachment(0, 0);
@@ -424,13 +426,13 @@ public class ScriptDialog extends BaseTransformDialog {
     TreeItem itemWaitFieldsIn = new TreeItem(itemInput, SWT.NULL);
     itemWaitFieldsIn.setText(BaseMessages.getString(PKG, 
"ScriptDialog.GettingFields.Label"));
     
itemWaitFieldsIn.setForeground(GuiResource.getInstance().getColorDirectory());
-    itemInput.setExpanded(true);
+    FolderTreeIcons.setExpanded(itemInput, true);
 
     // Display waiting message for output
     TreeItem itemWaitFieldsOut = new TreeItem(itemOutput, SWT.NULL);
     itemWaitFieldsOut.setText(BaseMessages.getString(PKG, 
"ScriptDialog.GettingFields.Label"));
     
itemWaitFieldsOut.setForeground(GuiResource.getInstance().getColorDirectory());
-    itemOutput.setExpanded(true);
+    FolderTreeIcons.setExpanded(itemOutput, true);
 
     //
     // Search the fields in the background
diff --git 
a/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImpl.java
new file mode 100644
index 0000000000..02b28e8cef
--- /dev/null
+++ 
b/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImpl.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget.svg;
+
+import java.awt.geom.Dimension2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
+import org.apache.batik.anim.dom.SVGDOMImplementation;
+import org.apache.batik.util.XMLResourceDescriptor;
+import org.apache.hop.core.SwingUniversalImageSvg;
+import org.apache.hop.core.SwtUniversalImage;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.svg.SvgImage;
+import org.apache.hop.core.svg.SvgSizing;
+import org.apache.hop.core.xml.XmlHandler;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.internal.resources.ResourceDirectory;
+import org.eclipse.rap.rwt.service.ResourceManager;
+import org.eclipse.swt.graphics.Device;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.internal.graphics.ExternalImageDescriptor;
+import org.eclipse.swt.internal.graphics.ImageFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+/**
+ * Publishes the sized SVG as an application resource and returns an {@link 
Image} whose client URL
+ * is that resource while its bounds stay the logical size. The browser then 
draws the vector at
+ * device resolution instead of scaling up a 1x bitmap.
+ *
+ * <p>The resource name is a digest of the sized XML, so one file serves every 
session, size and
+ * theme (the dark-mode colour swap is already in the document) and never 
needs invalidating. The
+ * registered file is also where such an image's pixels come from when a 
caller needs them ({@link
+ * #rasterizeInternal}) and what a badge overlay is built from ({@link 
#overlayInternal}): nothing
+ * has to be remembered per image.
+ */
+public class SvgImageFacadeImpl extends SvgImageFacade {
+
+  static final String RESOURCE_PREFIX = "hop/svg/";
+  private static final String LOCATION_PREFIX = ResourceDirectory.DIRNAME + 
"/";
+
+  /** RWT's register() is not atomic; two sessions publishing the same icon 
must not interleave. */
+  private static final Object REGISTER_LOCK = new Object();
+
+  @Override
+  protected Image createImageInternal(
+      Device device, Document document, Dimension2D intrinsicSize, int width, 
int height) {
+    try {
+      String svg =
+          SvgSizing.toXml(
+              document, intrinsicSize.getWidth(), intrinsicSize.getHeight(), 
width, height);
+      return publish(device, svg, width, height);
+    } catch (Exception e) {
+      // Typically no RAP context on this thread; the caller falls back to a 
bitmap.
+      LogChannel.UI.logDebug("Falling back to a bitmap for an SVG icon: " + 
e.getMessage());
+      return null;
+    }
+  }
+
+  @Override
+  protected ImageData rasterizeInternal(Image image, int zoom) {
+    try {
+      Document document = publishedSvg(image);
+      if (document == null) {
+        return null;
+      }
+      Rectangle bounds = image.getBounds();
+      BufferedImage bitmap =
+          new SwingUniversalImageSvg(new SvgImage(document))
+              .getAsBitmapForSize(
+                  SwtUniversalImage.pixelSize(bounds.width, zoom),
+                  SwtUniversalImage.pixelSize(bounds.height, zoom));
+      return SwtUniversalImage.toImageData(bitmap);
+    } catch (Exception e) {
+      LogChannel.UI.logDebug("Could not rasterise a vector icon: " + 
e.getMessage());
+      return null;
+    }
+  }
+
+  @Override
+  protected Image overlayInternal(
+      Device device, Image base, Image badge, int badgeSize, int margin) {
+    try {
+      Document baseDocument = publishedSvg(base);
+      Document badgeDocument = publishedSvg(badge);
+      if (baseDocument == null || badgeDocument == null) {
+        return null;
+      }
+      Rectangle bounds = base.getBounds();
+      // Nested <svg> elements keep each icon's own viewBox, so both scale 
exactly as they do alone.
+      Document composite =
+          SVGDOMImplementation.getDOMImplementation()
+              .createDocument(SVGDOMImplementation.SVG_NAMESPACE_URI, "svg", 
null);
+      Element root = composite.getDocumentElement();
+      root.setAttribute("width", String.valueOf(bounds.width));
+      root.setAttribute("height", String.valueOf(bounds.height));
+      root.setAttribute("viewBox", "0 0 " + bounds.width + " " + 
bounds.height);
+      root.appendChild(composite.importNode(baseDocument.getDocumentElement(), 
true));
+      Element badgeElement =
+          (Element) composite.importNode(badgeDocument.getDocumentElement(), 
true);
+      badgeElement.setAttribute("x", String.valueOf(bounds.width - badgeSize - 
margin));
+      badgeElement.setAttribute("y", String.valueOf(bounds.height - badgeSize 
- margin));
+      badgeElement.setAttribute("width", String.valueOf(badgeSize));
+      badgeElement.setAttribute("height", String.valueOf(badgeSize));
+      root.appendChild(badgeElement);
+      return publish(
+          device, XmlHandler.getXmlString(composite, false, false), 
bounds.width, bounds.height);
+    } catch (Exception e) {
+      LogChannel.UI.logDebug("Could not overlay a vector icon: " + 
e.getMessage());
+      return null;
+    }
+  }
+
+  /** Registers the XML once under its digest and returns an image of the 
given bounds for it. */
+  private static Image publish(Device device, String svg, int width, int 
height) throws Exception {
+    byte[] bytes = svg.getBytes(StandardCharsets.UTF_8);
+    String name = RESOURCE_PREFIX + digest(bytes) + ".svg";
+    ResourceManager resourceManager = RWT.getResourceManager();
+    synchronized (REGISTER_LOCK) {
+      if (!resourceManager.isRegistered(name)) {
+        resourceManager.register(name, new ByteArrayInputStream(bytes));
+      }
+    }
+    String location = resourceManager.getLocation(name);
+    return new ExternalImageDescriptor(location, width, 
height).createImage(device);
+  }
+
+  /** The registered SVG behind an image made by {@link #publish}, or null for 
any other image. */
+  private static Document publishedSvg(Image image) throws Exception {
+    if (image == null || image.isDisposed()) {
+      return null;
+    }
+    String path = ImageFactory.getImagePath(image);
+    if (path == null || !path.startsWith(LOCATION_PREFIX + RESOURCE_PREFIX)) {
+      return null;
+    }
+    String name = path.substring(LOCATION_PREFIX.length());
+    try (InputStream stream = 
RWT.getResourceManager().getRegisteredContent(name)) {
+      if (stream == null) {
+        return null;
+      }
+      return new 
SAXSVGDocumentFactory(XMLResourceDescriptor.getXMLParserClassName())
+          .createDocument(name, stream);
+    }
+  }
+
+  private static String digest(byte[] content) throws Exception {
+    byte[] hash = MessageDigest.getInstance("SHA-256").digest(content);
+    return HexFormat.of().formatHex(hash, 0, 16);
+  }
+}
diff --git 
a/rap/src/test/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImplTest.java
 
b/rap/src/test/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImplTest.java
new file mode 100644
index 0000000000..dd038688f5
--- /dev/null
+++ 
b/rap/src/test/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImplTest.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget.svg;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.awt.Dimension;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
+import org.apache.batik.util.XMLResourceDescriptor;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.service.ResourceManager;
+import org.eclipse.swt.graphics.Device;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+import org.eclipse.swt.graphics.RGB;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.internal.graphics.ImageFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+class SvgImageFacadeImplTest {
+
+  private static final String ICON =
+      "<svg xmlns=\"http://www.w3.org/2000/svg\"; width=\"24\" height=\"24\">"
+          + "<path fill=\"#0e3a5a\" d=\"M2 2h20v20H2z\"/></svg>";
+
+  private MockedStatic<RWT> rwt;
+  private ResourceManager resourceManager;
+  private final SvgImageFacadeImpl facade = new SvgImageFacadeImpl();
+  private final Device device = mock(Device.class);
+
+  /** What the mock resource manager has been handed, so it can serve it back 
like RWT does. */
+  private final Map<String, byte[]> registered = new HashMap<>();
+
+  @BeforeEach
+  void mockRwt() throws Exception {
+    resourceManager = mock(ResourceManager.class);
+    when(resourceManager.getLocation(anyString()))
+        .thenAnswer(invocation -> "rwt-resources/" + 
invocation.getArgument(0));
+    doAnswer(
+            invocation -> {
+              InputStream stream = invocation.getArgument(1);
+              registered.put(invocation.getArgument(0), stream.readAllBytes());
+              return null;
+            })
+        .when(resourceManager)
+        .register(anyString(), any(InputStream.class));
+    when(resourceManager.getRegisteredContent(anyString()))
+        .thenAnswer(
+            invocation -> {
+              byte[] bytes = registered.get(invocation.<String>getArgument(0));
+              return bytes == null ? null : new ByteArrayInputStream(bytes);
+            });
+    rwt = mockStatic(RWT.class);
+    rwt.when(RWT::getResourceManager).thenReturn(resourceManager);
+  }
+
+  @AfterEach
+  void closeRwt() {
+    rwt.close();
+  }
+
+  private static Document parse(String xml) throws Exception {
+    return new 
SAXSVGDocumentFactory(XMLResourceDescriptor.getXMLParserClassName())
+        .createDocument("icon.svg", new 
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+  }
+
+  @Test
+  void publishesTheSizedSvgAndPointsALogicallySizedImageAtIt() throws 
Exception {
+    Image image = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+
+    assertNotNull(image);
+    assertEquals(new Rectangle(0, 0, 16, 16), image.getBounds(), "tree cells 
stay 16px wide");
+
+    ArgumentCaptor<String> name = ArgumentCaptor.forClass(String.class);
+    verify(resourceManager).register(name.capture(), any(InputStream.class));
+    assertTrue(name.getValue().startsWith(SvgImageFacadeImpl.RESOURCE_PREFIX));
+    assertTrue(name.getValue().endsWith(".svg"));
+    assertEquals("rwt-resources/" + name.getValue(), 
ImageFactory.getImagePath(image));
+
+    String svg = new String(registered.get(name.getValue()), 
StandardCharsets.UTF_8);
+    assertTrue(svg.contains("width=\"16\""), svg);
+    assertTrue(svg.contains("height=\"16\""), svg);
+    assertTrue(svg.contains("viewBox=\"0 0 24 24\""), svg);
+  }
+
+  @Test
+  void sameSvgAndSizeReuseTheRegisteredResource() throws Exception {
+    Image first = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+    when(resourceManager.isRegistered(anyString())).thenReturn(true);
+
+    Image second = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+
+    verify(resourceManager).register(anyString(), any(InputStream.class));
+    assertEquals(ImageFactory.getImagePath(first), 
ImageFactory.getImagePath(second));
+  }
+
+  @Test
+  void differentSizeOrContentGetsItsOwnResource() throws Exception {
+    Image small = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+    Image large = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 32, 32);
+    Image other =
+        facade.createImageInternal(
+            device, parse(ICON.replace("#0e3a5a", "#c8e7fa")), new 
Dimension(24, 24), 16, 16);
+
+    String smallPath = ImageFactory.getImagePath(small);
+    assertNotEquals(smallPath, ImageFactory.getImagePath(large));
+    assertNotEquals(smallPath, ImageFactory.getImagePath(other));
+  }
+
+  @Test
+  void vectorImagesCanStillBeRasterisedAtAnyZoom() throws Exception {
+    Image image = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+
+    ImageData at100 = facade.rasterizeInternal(image, 100);
+    ImageData at200 = facade.rasterizeInternal(image, 200);
+
+    assertEquals(16, at100.width);
+    assertEquals(16, at100.height);
+    assertEquals(32, at200.width);
+    // The square path covers the centre with the icon's fill colour.
+    assertEquals(new RGB(0x0e, 0x3a, 0x5a), 
at100.palette.getRGB(at100.getPixel(8, 8)));
+    assertEquals(255, at200.getAlpha(16, 16));
+    assertEquals(0, at100.getAlpha(0, 0), "outside the path stays 
transparent");
+  }
+
+  @Test
+  void rasterisingAnImageThatIsNotVectorBackedIsNotOurBusiness() {
+    Image foreign = mock(Image.class);
+    assertNull(facade.rasterizeInternal(foreign, 100));
+  }
+
+  @Test
+  void overlayNestsTheBadgeInTheBottomRightCornerOfTheBase() throws Exception {
+    Image base = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+    Image badge =
+        facade.createImageInternal(
+            device, parse(ICON.replace("#0e3a5a", "#ff0000")), new 
Dimension(24, 24), 8, 8);
+
+    Image badged = facade.overlayInternal(device, base, badge, 8, 1);
+
+    assertNotNull(badged);
+    assertEquals(new Rectangle(0, 0, 16, 16), badged.getBounds());
+    String name = 
ImageFactory.getImagePath(badged).substring("rwt-resources/".length());
+    Element root =
+        parse(new String(registered.get(name), 
StandardCharsets.UTF_8)).getDocumentElement();
+    NodeList nested = root.getElementsByTagName("svg");
+    assertEquals(2, nested.getLength(), "base and badge as nested <svg>");
+    Element nestedBase = (Element) nested.item(0);
+    assertEquals("16", nestedBase.getAttribute("width"));
+    assertEquals("0 0 24 24", nestedBase.getAttribute("viewBox"), "keeps its 
own scaling");
+    Element nestedBadge = (Element) nested.item(1);
+    assertEquals("7", nestedBadge.getAttribute("x"));
+    assertEquals("7", nestedBadge.getAttribute("y"));
+    assertEquals("8", nestedBadge.getAttribute("width"));
+    assertEquals("8", nestedBadge.getAttribute("height"));
+
+    ImageData pixels = facade.rasterizeInternal(badged, 100);
+    assertEquals(new RGB(0xff, 0, 0), 
pixels.palette.getRGB(pixels.getPixel(11, 11)), "badge");
+    assertEquals(new RGB(0x0e, 0x3a, 0x5a), 
pixels.palette.getRGB(pixels.getPixel(3, 3)), "base");
+  }
+
+  @Test
+  void overlayNeedsBothImagesToBeVectorBacked() throws Exception {
+    Image base = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+    assertNull(facade.overlayInternal(device, base, mock(Image.class), 8, 1));
+    assertNull(facade.overlayInternal(device, mock(Image.class), base, 8, 1));
+  }
+
+  @Test
+  void withoutARapContextTheCallerRasterises() throws Exception {
+    rwt.when(RWT::getResourceManager).thenThrow(new IllegalStateException("No 
context"));
+
+    Image image = facade.createImageInternal(device, parse(ICON), new 
Dimension(24, 24), 16, 16);
+
+    assertNull(image);
+    verify(resourceManager, never()).register(anyString(), 
any(InputStream.class));
+  }
+}
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImpl.java
new file mode 100644
index 0000000000..22805389dd
--- /dev/null
+++ 
b/rcp/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacadeImpl.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget.svg;
+
+import java.awt.geom.Dimension2D;
+import org.eclipse.swt.graphics.Device;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+import org.w3c.dom.Document;
+
+/** Desktop SWT rasterises per monitor zoom itself, so there is nothing to do 
here. */
+public class SvgImageFacadeImpl extends SvgImageFacade {
+
+  @Override
+  protected Image createImageInternal(
+      Device device, Document document, Dimension2D intrinsicSize, int width, 
int height) {
+    return null;
+  }
+
+  @Override
+  protected ImageData rasterizeInternal(Image image, int zoom) {
+    return null;
+  }
+
+  @Override
+  protected Image overlayInternal(
+      Device device, Image base, Image badge, int badgeSize, int margin) {
+    return null;
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/widget/FolderTreeIconsTest.java 
b/rcp/src/test/java/org/apache/hop/ui/core/widget/FolderTreeIconsTest.java
new file mode 100644
index 0000000000..1f631b7172
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/core/widget/FolderTreeIconsTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/** Folder tree items show the closed folder while collapsed and the open one 
while expanded. */
+@Tag("uitest")
+class FolderTreeIconsTest extends SwtBotTestBase {
+
+  private static final String TREE_NAME = "FolderTreeIconsTest";
+
+  private static Image closed() {
+    return GuiResource.getInstance().getImageFolder();
+  }
+
+  private static Image open() {
+    return GuiResource.getInstance().getImageFolderOpen();
+  }
+
+  /** root(folder) > child(folder) > leaf(file); plus a sibling category with 
a non-folder image. */
+  private static Tree buildTree(org.eclipse.swt.widgets.Shell shell) {
+    shell.setLayout(new FillLayout());
+    Tree tree = new Tree(shell, SWT.SINGLE);
+    TreeItem root = new TreeItem(tree, SWT.NONE);
+    root.setText("root");
+    root.setImage(closed());
+    TreeItem child = new TreeItem(root, SWT.NONE);
+    child.setText("child");
+    child.setImage(closed());
+    TreeItem leaf = new TreeItem(child, SWT.NONE);
+    leaf.setText("leaf");
+    leaf.setImage(GuiResource.getInstance().getImageFile());
+    TreeItem other = new TreeItem(tree, SWT.NONE);
+    other.setText("other");
+    other.setImage(GuiResource.getInstance().getImageFile());
+    new TreeItem(other, SWT.NONE).setText("other-child");
+    FolderTreeIcons.install(tree);
+    return tree;
+  }
+
+  private static void fire(Tree tree, int type, TreeItem item) {
+    Event event = new Event();
+    event.item = item;
+    tree.notifyListeners(type, event);
+  }
+
+  @Test
+  void userExpandAndCollapseSwapTheFolderIcon() {
+    AtomicReference<Tree> treeRef = new AtomicReference<>();
+    withScene(
+        shell -> treeRef.set(buildTree(shell)),
+        bot ->
+            display.syncExec(
+                () -> {
+                  Tree tree = treeRef.get();
+                  TreeItem root = tree.getItem(0);
+                  TreeItem other = tree.getItem(1);
+
+                  fire(tree, SWT.Expand, root);
+                  assertSame(open(), root.getImage(), "expanded folder shows 
the open icon");
+                  assertSame(closed(), root.getItem(0).getImage(), "children 
are not touched");
+
+                  fire(tree, SWT.Collapse, root);
+                  assertSame(closed(), root.getImage(), "collapsed folder 
shows the closed icon");
+
+                  Image otherImage = other.getImage();
+                  fire(tree, SWT.Expand, other);
+                  assertSame(otherImage, other.getImage(), "non-folder images 
are left alone");
+                }));
+  }
+
+  @Test
+  void programmaticSetExpandedUpdatesStateAndIcon() {
+    AtomicReference<Tree> treeRef = new AtomicReference<>();
+    withScene(
+        shell -> treeRef.set(buildTree(shell)),
+        bot ->
+            display.syncExec(
+                () -> {
+                  TreeItem root = treeRef.get().getItem(0);
+                  TreeItem child = root.getItem(0);
+
+                  FolderTreeIcons.setExpanded(root, true);
+                  assertTrue(root.getExpanded());
+                  assertSame(open(), root.getImage());
+
+                  FolderTreeIcons.setExpanded(child, true);
+                  assertSame(open(), child.getImage());
+
+                  FolderTreeIcons.setExpanded(root, false);
+                  assertFalse(root.getExpanded());
+                  assertSame(closed(), root.getImage());
+                  assertSame(open(), child.getImage(), "collapsing the parent 
leaves the child");
+                }));
+  }
+
+  @Test
+  void expandingAnEmptyFolderKeepsTheClosedIcon() {
+    AtomicReference<Tree> treeRef = new AtomicReference<>();
+    withScene(
+        shell -> treeRef.set(buildTree(shell)),
+        bot ->
+            display.syncExec(
+                () -> {
+                  TreeItem empty = new TreeItem(treeRef.get(), SWT.NONE);
+                  empty.setText("empty");
+                  empty.setImage(closed());
+
+                  FolderTreeIcons.setExpanded(empty, true);
+
+                  assertFalse(empty.getExpanded(), "SWT ignores expanding a 
childless item");
+                  assertSame(closed(), empty.getImage(), "so the icon must not 
pretend otherwise");
+                }));
+  }
+
+  @Test
+  void treeMemoryRestoreShowsTheOpenIcon() {
+    AtomicReference<Tree> treeRef = new AtomicReference<>();
+    withScene(
+        shell -> treeRef.set(buildTree(shell)),
+        bot ->
+            display.syncExec(
+                () -> {
+                  Tree tree = treeRef.get();
+                  TreeItem root = tree.getItem(0);
+                  TreeMemory.getInstance().storeExpanded(TREE_NAME, root, 
true);
+
+                  TreeMemory.setExpandedFromMemory(tree, TREE_NAME);
+
+                  assertTrue(root.getExpanded());
+                  assertSame(open(), root.getImage(), "memory-restored folders 
open their icon");
+                  assertSame(closed(), root.getItem(0).getImage());
+                }));
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java 
b/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
index 750ee54344..d1b679bba5 100644
--- a/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
+++ b/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
@@ -20,7 +20,9 @@ package org.apache.hop.core;
 import java.awt.image.BufferedImage;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Supplier;
 import org.apache.hop.core.exception.HopRuntimeException;
+import org.apache.hop.ui.core.widget.svg.SvgImageFacade;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.graphics.Device;
 import org.eclipse.swt.graphics.Image;
@@ -85,12 +87,25 @@ public abstract class SwtUniversalImage {
 
   /** Method getAsBitmapForSize(..., angle) can't be called, because it 
returns bigger picture. */
   public synchronized Image getAsBitmapForSize(Device device, int width, int 
height) {
+    return cached(width + "x" + height, () -> renderSimple(device, width, 
height));
+  }
+
+  /**
+   * Like {@link #getAsBitmapForSize(Device, int, int)} but guaranteed to 
carry pixels. Use it where
+   * the image is read back ({@link Image#getImageData()}, {@code 
SWT.IMAGE_GRAY}): on Hop Web the
+   * plain variant may be a vector the client renders, which has no pixel data 
on the server.
+   */
+  public synchronized Image getAsRasterForSize(Device device, int width, int 
height) {
+    return getAsBitmapForSize(device, width, height);
+  }
+
+  /** The image under {@code key}, rendered on first use and disposed with 
this instance. */
+  protected synchronized Image cached(String key, Supplier<Image> renderer) {
     checkDisposed();
 
-    String key = width + "x" + height;
     Image result = cache.get(key);
     if (result == null) {
-      result = renderSimple(device, width, height);
+      result = renderer.get();
       cache.put(key, result);
     }
     return result;
@@ -154,6 +169,11 @@ public abstract class SwtUniversalImage {
     if (isDpiAwareImageProviderSupported()) {
       return SwtDesktopDpiImages.getImageData(image, zoom);
     }
+    // A vector-backed Hop Web image has no pixels of its own; rasterise it at 
this zoom instead.
+    ImageData vector = SvgImageFacade.rasterize(image, zoom);
+    if (vector != null) {
+      return vector;
+    }
     ImageData data = image.getImageData();
     if (zoom == 100) {
       return data;
@@ -162,7 +182,7 @@ public abstract class SwtUniversalImage {
   }
 
   /** Converts BufferedImage to SWT ImageData with alpha channel. */
-  static ImageData toImageData(BufferedImage img) {
+  public static ImageData toImageData(BufferedImage img) {
     PaletteData palette = new PaletteData(0xFF0000, 0xFF00, 0xFF);
     ImageData data = new ImageData(img.getWidth(), img.getHeight(), 32, 
palette);
     for (int y = 0; y < data.height; y++) {
diff --git a/ui/src/main/java/org/apache/hop/core/SwtUniversalImageSvg.java 
b/ui/src/main/java/org/apache/hop/core/SwtUniversalImageSvg.java
index 6c485970ca..a106e600da 100644
--- a/ui/src/main/java/org/apache/hop/core/SwtUniversalImageSvg.java
+++ b/ui/src/main/java/org/apache/hop/core/SwtUniversalImageSvg.java
@@ -30,14 +30,19 @@ import org.apache.batik.gvt.GraphicsNode;
 import org.apache.hop.core.svg.SvgDarkModeContrast;
 import org.apache.hop.core.svg.SvgImage;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.widget.svg.SvgImageFacade;
 import org.eclipse.swt.graphics.Device;
 import org.eclipse.swt.graphics.Image;
+import org.w3c.dom.Document;
 import org.w3c.dom.svg.SVGDocument;
 
 public class SwtUniversalImageSvg extends SwtUniversalImage {
   private final GraphicsNode svgGraphicsNode;
   private final Dimension2D svgGraphicsSize;
 
+  /** What gets rendered: the contrast-adjusted clone in dark mode, else the 
original. */
+  private final Document svgDocument;
+
   static {
     // workaround due to known issue in batik 1.8 - 
https://issues.apache.org/jira/browse/BATIK-1125
     ImageTagRegistry registry = ImageTagRegistry.getRegistry();
@@ -62,12 +67,14 @@ public class SwtUniversalImageSvg extends SwtUniversalImage 
{
 
       svgGraphicsNode = builder.build(ctx, clonedDocument);
       svgGraphicsSize = ctx.getDocumentSize();
+      svgDocument = clonedDocument;
     } else {
       // get GraphicsNode and size from svg document
       DocumentLoader documentLoader = new DocumentLoader(userAgentAdapter);
       BridgeContext ctx = new BridgeContext(userAgentAdapter, documentLoader);
       svgGraphicsNode = builder.build(ctx, svg.getDocument());
       svgGraphicsSize = ctx.getDocumentSize();
+      svgDocument = svg.getDocument();
     }
   }
 
@@ -81,6 +88,27 @@ public class SwtUniversalImageSvg extends SwtUniversalImage {
 
   @Override
   protected Image renderSimple(Device device, int width, int height) {
+    if (!isDpiAwareImageProviderSupported()) {
+      // Hop Web: hand the browser the vector so it rasterises at device 
resolution.
+      Image vector =
+          SvgImageFacade.createImage(device, svgDocument, svgGraphicsSize, 
width, height);
+      if (vector != null) {
+        return vector;
+      }
+    }
+    return renderRaster(device, width, height);
+  }
+
+  @Override
+  public synchronized Image getAsRasterForSize(Device device, int width, int 
height) {
+    if (isDpiAwareImageProviderSupported()) {
+      // Desktop bitmaps always carry pixels; share the regular cache entry.
+      return getAsBitmapForSize(device, width, height);
+    }
+    return cached("raster/" + width + "x" + height, () -> renderRaster(device, 
width, height));
+  }
+
+  private Image renderRaster(Device device, int width, int height) {
     return createDpiAwareImage(device, width, height, (w, h) -> 
toImageData(renderSvg(w, h, 0d)));
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
 
b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
index 5973e93363..7c1c8fbd85 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
@@ -59,6 +59,7 @@ import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
@@ -268,6 +269,7 @@ public class DatabaseExplorerDialog extends Dialog {
 
     // Create tree
     wTree = new HopTree(composite, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    FolderTreeIcons.install(wTree);
     wTree.setLayoutData(
         FormDataBuilder.builder().top(toolBar, 
margin).bottom().fullWidth().build());
     wTree.addListener(SWT.DefaultSelection, this::openTreeItem);
@@ -345,7 +347,7 @@ public class DatabaseExplorerDialog extends Dialog {
 
   private void expandAllItems(TreeItem[] items, boolean expand) {
     for (TreeItem item : items) {
-      item.setExpanded(expand);
+      FolderTreeIcons.setExpanded(item, expand);
       if (item.getItemCount() > 0) {
         expandAllItems(item.getItems(), expand);
       }
@@ -465,7 +467,7 @@ public class DatabaseExplorerDialog extends Dialog {
     }
 
     // Always expand the root item
-    connectionItem.setExpanded(true);
+    FolderTreeIcons.setExpanded(connectionItem, true);
 
     wTree.setRedraw(true);
     shell.setCursor(null);
@@ -555,7 +557,7 @@ public class DatabaseExplorerDialog extends Dialog {
         }
       }
     }
-    parent.setExpanded(expanded);
+    FolderTreeIcons.setExpanded(parent, expanded);
   }
 
   /**
@@ -610,7 +612,7 @@ public class DatabaseExplorerDialog extends Dialog {
         }
       }
     }
-    parent.setExpanded(expanded);
+    FolderTreeIcons.setExpanded(parent, expanded);
   }
 
   private boolean matchesFilter(String name, String schemaName) {
@@ -973,7 +975,7 @@ public class DatabaseExplorerDialog extends Dialog {
     DatabaseTreeNode node = (DatabaseTreeNode) item.getData();
     if (!node.isTableLike()) {
       // Expand/Collapse hierarchy
-      item.setExpanded(!item.getExpanded());
+      FolderTreeIcons.setExpanded(item, !item.getExpanded());
       return;
     }
 
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
index 5e5405b65b..a07a887a45 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
@@ -164,6 +164,7 @@ public class GuiResource {
   private SwtUniversalImage imageArrowCandidate;
   private SwtUniversalImage imageServer;
   private SwtUniversalImage imageFolder;
+  private SwtUniversalImage imageFolderOpen;
   private SwtUniversalImage imageFile;
   private SwtUniversalImage imageEdit;
   private SwtUniversalImage imageCopyRows;
@@ -461,6 +462,7 @@ public class GuiResource {
     imageFile.dispose();
     imageFindReplace.dispose();
     imageFolder.dispose();
+    imageFolderOpen.dispose();
     imageMissing.dispose();
     imageVariable.dispose();
     imageHash.dispose();
@@ -881,6 +883,7 @@ public class GuiResource {
     imageNaming = SwtSvgImageUtil.getImageAsResource(display, 
"ui/images/naming.svg");
     imageFile = SwtSvgImageUtil.getImageAsResource(display, 
"ui/images/file.svg");
     imageFolder = SwtSvgImageUtil.getImageAsResource(display, 
"ui/images/folder.svg");
+    imageFolderOpen = SwtSvgImageUtil.getImageAsResource(display, 
"ui/images/folder-open.svg");
     imagePartitionSchema =
         SwtSvgImageUtil.getImageAsResource(display, 
"ui/images/partition_schema.svg");
     imageDatabase = SwtSvgImageUtil.getImageAsResource(display, 
"ui/images/database.svg");
@@ -1341,12 +1344,25 @@ public class GuiResource {
   }
 
   /**
-   * @return the imageArrow
+   * Closed folder, tree-item sized. Pair with {@link #getImageFolderOpen()} 
through {@link
+   * org.apache.hop.ui.core.widget.FolderTreeIcons} to swap the icon on 
expand/collapse.
+   *
+   * @return the closed folder image
    */
   public Image getImageFolder() {
     return getZoomedImaged(imageFolder, display, ConstUi.SMALL_ICON_SIZE, 
ConstUi.SMALL_ICON_SIZE);
   }
 
+  /**
+   * Open folder, tree-item sized: the expanded counterpart of {@link 
#getImageFolder()}.
+   *
+   * @return the open folder image
+   */
+  public Image getImageFolderOpen() {
+    return getZoomedImaged(
+        imageFolderOpen, display, ConstUi.SMALL_ICON_SIZE, 
ConstUi.SMALL_ICON_SIZE);
+  }
+
   /**
    * @return the imageFile
    */
@@ -1634,10 +1650,15 @@ public class GuiResource {
         k -> {
           SwtUniversalImage svg = SwtSvgImageUtil.getUniversalImage(display, 
classLoader, location);
 
-          Image zoomedImaged = getZoomedImaged(svg, display, width, height);
           Image loaded;
           if (disabled) {
-            Image gray = new Image(display, zoomedImaged, SWT.IMAGE_GRAY);
+            // Grayscaling reads the pixels back, which a vector-backed Hop 
Web image lacks.
+            Image raster =
+                svg.getAsRasterForSize(
+                    display,
+                    ConstUi.zoomedIconSize(width, zoomFactor),
+                    ConstUi.zoomedIconSize(height, zoomFactor));
+            Image gray = new Image(display, raster, SWT.IMAGE_GRAY);
             float factor = PropsUi.getInstance().isDarkMode() ? 0.4f : 2.5f;
             loaded =
                 SwtUniversalImage.createDpiAwareImage(
@@ -1646,6 +1667,7 @@ public class GuiResource {
                         applyDisabledContrast(
                             SwtUniversalImage.getImageDataAtZoom(gray, zoom), 
factor));
           } else {
+            Image zoomedImaged = getZoomedImaged(svg, display, width, height);
             loaded = new Image(display, zoomedImaged, SWT.IMAGE_COPY);
           }
 
diff --git a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
index eb72f750f2..ce0e7eace6 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/vfs/HopVfsFileDialog.java
@@ -64,6 +64,7 @@ import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.NamingSchemeWidgetSupport;
@@ -557,6 +558,7 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
         new HopTree(
             browseSash, (multiSelection ? SWT.MULTI : SWT.SINGLE) | 
SWT.H_SCROLL | SWT.V_SCROLL);
     PropsUi.setLook(wBrowser);
+    FolderTreeIcons.install(wBrowser);
     wBrowser.setHeaderVisible(true);
     wBrowser.setLinesVisible(false);
 
@@ -1093,7 +1095,7 @@ public class HopVfsFileDialog implements IFileDialog, 
IDirectoryDialog {
 
       populateFolder(activeFolder, parentFolderItem);
 
-      parentFolderItem.setExpanded(true);
+      FolderTreeIcons.setExpanded(parentFolderItem, true);
 
       updateSelection();
     } catch (Exception e) {
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
index c4854ab623..69d400c67f 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/FileTree.java
@@ -90,6 +90,7 @@ public class FileTree extends Composite {
     btnFindDependencies.setLayoutData(new 
FormDataBuilder().left().fullWidth().bottom().result());
 
     tree = new HopTree(this, SWT.CHECK | SWT.MULTI | SWT.BORDER);
+    FolderTreeIcons.install(tree);
     PropsUi.setLook(tree);
     tree.setLayoutData(
         new FormDataBuilder()
@@ -151,7 +152,7 @@ public class FileTree extends Composite {
 
   /** Expand the given item and all descendants so the default selection is 
visible. */
   private void expandAll(TreeItem item) {
-    item.setExpanded(true);
+    FolderTreeIcons.setExpanded(item, true);
     for (TreeItem child : item.getItems()) {
       if (FOLDER.equals(child.getData("type"))) {
         expandAll(child);
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/FolderTreeIcons.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/FolderTreeIcons.java
new file mode 100644
index 0000000000..8f02466e1f
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/FolderTreeIcons.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
+
+/**
+ * Shows a closed folder on collapsed tree items and an open folder on 
expanded ones.
+ *
+ * <p>Items keep using {@link GuiResource#getImageFolder()} as their image; 
this class only swaps it
+ * with {@link GuiResource#getImageFolderOpen()} and back. Items carrying any 
other image are left
+ * alone. {@link #install(Tree)} covers user expand/collapse. SWT fires no 
event for a programmatic
+ * {@link TreeItem#setExpanded(boolean)}, so expand through {@link 
#setExpanded(TreeItem, boolean)}.
+ */
+public final class FolderTreeIcons {
+
+  private FolderTreeIcons() {}
+
+  /** Swaps the folder icon of the item the user expands or collapses. */
+  public static void install(Tree tree) {
+    tree.addListener(SWT.Expand, e -> setOpen((TreeItem) e.item, true));
+    tree.addListener(SWT.Collapse, e -> setOpen((TreeItem) e.item, false));
+  }
+
+  /**
+   * {@link TreeItem#setExpanded(boolean)} that also updates the folder icon. 
The icon follows the
+   * state the item ends up in: expanding an item without children is a no-op 
on every platform.
+   */
+  public static void setExpanded(TreeItem item, boolean expanded) {
+    if (item == null || item.isDisposed()) {
+      return;
+    }
+    item.setExpanded(expanded);
+    setOpen(item, item.getExpanded());
+  }
+
+  private static void setOpen(TreeItem item, boolean open) {
+    if (item == null || item.isDisposed()) {
+      return;
+    }
+    GuiResource guiResource = GuiResource.getInstance();
+    Image closedImage = guiResource.getImageFolder();
+    Image openImage = guiResource.getImageFolderOpen();
+    Image current = item.getImage();
+    if (open && current == closedImage) {
+      item.setImage(openImage);
+    } else if (!open && current == openImage) {
+      item.setImage(closedImage);
+    }
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
index 8fe7bd04e9..fd9e4e4805 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
@@ -190,7 +190,7 @@ public class TreeMemory {
 
     String[] path = ConstUi.getTreeStrings(treeItem);
     boolean expanded = treeMemory.isExpanded(treeName, path);
-    treeItem.setExpanded(expanded);
+    FolderTreeIcons.setExpanded(treeItem, expanded);
 
     TreeItem[] items = treeItem.getItems();
     for (TreeItem item : items) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacade.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacade.java
new file mode 100644
index 0000000000..e680410162
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgImageFacade.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget.svg;
+
+import java.awt.geom.Dimension2D;
+import org.apache.hop.ui.hopgui.ImplementationLoader;
+import org.eclipse.swt.graphics.Device;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+import org.w3c.dom.Document;
+
+/**
+ * Widget images that the client draws as vectors.
+ *
+ * <p>Hop Web has no per-monitor zoom: an {@link Image} is one bitmap at its 
logical size and the
+ * browser scales it up on a high-DPI screen, so a 16px tree icon comes out 
blurry. The RAP
+ * implementation instead publishes the SVG, sized to the requested pixels, as 
a web resource and
+ * returns an image that points at it; the browser then rasterises the vector 
at device resolution.
+ * On desktop, where SWT re-rasterises per zoom itself, the implementation 
returns {@code null}.
+ */
+public abstract class SvgImageFacade {
+
+  private static final SvgImageFacade IMPL;
+
+  static {
+    IMPL = (SvgImageFacade) 
ImplementationLoader.newInstance(SvgImageFacade.class);
+  }
+
+  /**
+   * @param device the display the image belongs to
+   * @param document the SVG, already colour-adjusted for the theme
+   * @param intrinsicSize the size the drawing was authored for, in user units
+   * @param width the requested width in pixels
+   * @param height the requested height in pixels
+   * @return an image the client renders as a vector, or {@code null} when 
this platform or thread
+   *     cannot provide one and the caller has to rasterise
+   */
+  public static Image createImage(
+      Device device, Document document, Dimension2D intrinsicSize, int width, 
int height) {
+    return IMPL.createImageInternal(device, document, intrinsicSize, width, 
height);
+  }
+
+  protected abstract Image createImageInternal(
+      Device device, Document document, Dimension2D intrinsicSize, int width, 
int height);
+
+  /**
+   * Pixels for an image handed out by {@link #createImage}, for the few 
callers that read an image
+   * back (badge compositing, grayscaling).
+   *
+   * @param image an image, vector-backed or not
+   * @param zoom the SWT zoom percentage (100, 200, ...) the pixels are for
+   * @return the image rasterised at that zoom, or {@code null} when it is not 
vector-backed
+   */
+  public static ImageData rasterize(Image image, int zoom) {
+    return IMPL.rasterizeInternal(image, zoom);
+  }
+
+  protected abstract ImageData rasterizeInternal(Image image, int zoom);
+
+  /**
+   * {@code badge} drawn into the bottom-right corner of {@code base}, as one 
image the client still
+   * renders as a vector. Sizes are in the logical pixels of {@code base}.
+   *
+   * @return the badged image, or {@code null} when either image is not 
vector-backed and the caller
+   *     has to composite pixels instead
+   */
+  public static Image overlay(Device device, Image base, Image badge, int 
badgeSize, int margin) {
+    return IMPL.overlayInternal(device, base, badge, badgeSize, margin);
+  }
+
+  protected abstract Image overlayInternal(
+      Device device, Image base, Image badge, int badgeSize, int margin);
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
index 7c08862109..4fe0b1a788 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
@@ -25,6 +25,7 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.bus.HopGuiEvents;
 import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.TreeMemory;
 import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
@@ -171,6 +172,7 @@ public class GraphPaletteTree extends Composite {
     PropsUi.setLook(border);
 
     tree = new Tree(border, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    FolderTreeIcons.install(tree);
     tree.setHeaderVisible(false);
     PropsUi.setLook(tree);
     FormData treeFd = new FormData();
@@ -451,7 +453,7 @@ public class GraphPaletteTree extends Composite {
   }
 
   private void expandTreeItem(TreeItem item, boolean expanded) {
-    item.setExpanded(expanded);
+    FolderTreeIcons.setExpanded(item, expanded);
     TreeMemory.getInstance().storeExpanded(treeMemoryName, item, expanded);
     for (TreeItem child : item.getItems()) {
       expandTreeItem(child, expanded);
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
index 4921db926a..fd2b5f5a05 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
@@ -54,6 +54,7 @@ import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.metadata.MetadataManager;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.TreeMemory;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
 import org.apache.hop.ui.hopgui.HopGui;
@@ -192,6 +193,7 @@ public class DatabaseWorkbench extends Composite implements 
TabClosable {
     toolBar.pack();
 
     tree = new Tree(treeBorder, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    FolderTreeIcons.install(tree);
     PropsUi.setLook(tree);
     tree.setLayoutData(
         new FormDataBuilder().top(toolBar, 
PropsUi.getMargin()).bottom().fullWidth().result());
@@ -472,7 +474,7 @@ public class DatabaseWorkbench extends Composite implements 
TabClosable {
             DatabaseTreeNode.connection(state.getDatabaseMeta().getName(), 
state.isConnected()));
         if (state.isConnected() && state.getInformation() != null) {
           fillConnectionChildren(connectionItem, state);
-          connectionItem.setExpanded(true);
+          FolderTreeIcons.setExpanded(connectionItem, true);
         }
       }
       restoreSchemaExpandState();
@@ -510,7 +512,8 @@ public class DatabaseWorkbench extends Composite implements 
TabClosable {
     for (TreeItem child : parent.getItems()) {
       Object data = child.getData();
       if (data instanceof DatabaseTreeNode node && 
remembersExpandState(node.getKind())) {
-        child.setExpanded(
+        FolderTreeIcons.setExpanded(
+            child,
             TreeMemory.getInstance().isExpanded(TREE_MEMORY_KEY, 
ConstUi.getTreeStrings(child)));
       }
       restoreSchemaExpandState(child);
@@ -529,12 +532,12 @@ public class DatabaseWorkbench extends Composite 
implements TabClosable {
         if (data instanceof DatabaseTreeNode node
             && remembersExpandState(node.getKind())
             && child.getItemCount() > 0) {
-          child.setExpanded(true);
+          FolderTreeIcons.setExpanded(child, true);
           any = true;
         }
       }
       if (any) {
-        connection.setExpanded(true);
+        FolderTreeIcons.setExpanded(connection, true);
       }
     }
   }
@@ -729,7 +732,7 @@ public class DatabaseWorkbench extends Composite implements 
TabClosable {
     folder.setImage(GuiResource.getInstance().getImageFolder());
     folder.setData(DatabaseTreeNode.folder(connectionName, folderName));
     addTables(folder, connectionName, null, names, kind);
-    folder.setExpanded(true);
+    FolderTreeIcons.setExpanded(folder, true);
   }
 
   private void addTables(
@@ -819,7 +822,7 @@ public class DatabaseWorkbench extends Composite implements 
TabClosable {
       return;
     }
     if (item != null && item.getItemCount() > 0 && (node == null || 
!node.isTableLike())) {
-      item.setExpanded(!item.getExpanded());
+      FolderTreeIcons.setExpanded(item, !item.getExpanded());
       return;
     }
     previewTable();
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 21a51c5eb7..270a1406e1 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -95,6 +95,7 @@ import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.NamingSchemeWidgetSupport;
 import org.apache.hop.ui.core.widget.TreeMemory;
@@ -750,6 +751,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     // Lazy loading...
     //
     tree.addListener(SWT.Expand, this::lazyLoadFolderOnExpand);
+    FolderTreeIcons.install(tree);
 
     // Create context menu...
     //
@@ -1192,9 +1194,9 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       if (tif.folder) {
         if (!item.getExpanded()) {
           lazyLoadFolderOnExpand(event);
-          item.setExpanded(true);
+          FolderTreeIcons.setExpanded(item, true);
         } else {
-          item.setExpanded(false);
+          FolderTreeIcons.setExpanded(item, false);
         }
         TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, item, 
item.getExpanded());
       } else {
@@ -1214,7 +1216,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           if (expanded) {
             ensureFolderLoaded(item);
           }
-          item.setExpanded(expanded);
+          FolderTreeIcons.setExpanded(item, expanded);
           TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, item, 
expanded);
         } else {
           IHopFileTypeHandler handler =
@@ -2798,7 +2800,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     }
     if (tif != null && tif.folder && isDescendant(tif.path, filename)) {
       ensureFolderLoaded(item);
-      item.setExpanded(true);
+      FolderTreeIcons.setExpanded(item, true);
       TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, item, true);
       for (TreeItem child : item.getItems()) {
         if (selectInTree(child, filename)) {
@@ -3603,7 +3605,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       // which showed blank rows. Load real children first.
       ensureFolderLoaded(item);
     }
-    item.setExpanded(expand);
+    FolderTreeIcons.setExpanded(item, expand);
     TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, item, expand);
 
     for (TreeItem childItem : item.getItems()) {
@@ -3911,7 +3913,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
         setTreeItemData(rootItem, rootNode.getPath(), rootNode.getName(), 
fileType, 0, true, true);
 
         renderFilteredChildren(rootItem, rootNode, 0);
-        rootItem.setExpanded(true);
+        FolderTreeIcons.setExpanded(rootItem, true);
         TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, rootItem, 
true);
       } finally {
         tree.setRedraw(true);
@@ -4056,7 +4058,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       if (child.isFolder()) {
         renderFilteredChildren(childItem, child, depth + 1);
         if (childItem.getItemCount() > 0) {
-          childItem.setExpanded(true);
+          FolderTreeIcons.setExpanded(childItem, true);
           TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, 
childItem, true);
         }
       }
@@ -4153,7 +4155,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
     String[] treePath = ConstUi.getTreeStrings(folderItem);
     boolean wasExpanded = folderItem.getExpanded();
     if (wasExpanded) {
-      folderItem.setExpanded(false);
+      FolderTreeIcons.setExpanded(folderItem, false);
       // Collapse listener clears TreeMemory; put the remembered expand state 
back.
       TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, treePath, 
true);
     }
@@ -4310,7 +4312,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
         // Always expand root item when filtering
         if (!Utils.isEmpty(filterText)) {
-          rootItem.setExpanded(true);
+          FolderTreeIcons.setExpanded(rootItem, true);
           TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, rootItem, 
true);
         } else {
           TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, rootItem, 
true);
@@ -4319,7 +4321,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           // The TreeMemory will be applied either by restoreTreeState() or 
setExpandedFromMemory()
           if (treeStateBeforeFilter == null) {
             // Only restore from memory if we're not about to restore from 
saved state
-            rootItem.setExpanded(true);
+            FolderTreeIcons.setExpanded(rootItem, true);
             restoreTreeItemExpandedFromMemory(rootItem);
           }
         }
@@ -4391,6 +4393,11 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
   }
 
   private void setItemImage(TreeItem treeItem, IHopFileType fileType) {
+    if (fileType instanceof FolderFileType) {
+      // Shared closed-folder image: FolderTreeIcons swaps it for the open one 
on expand.
+      treeItem.setImage(GuiResource.getInstance().getImageFolder());
+      return;
+    }
     Image image = typeImageMap.get(fileType.getName());
     if (image != null) {
       treeItem.setImage(image);
@@ -4636,7 +4643,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
       if (expanded) {
         ensureFolderLoaded(item);
       }
-      item.setExpanded(expanded);
+      FolderTreeIcons.setExpanded(item, expanded);
     }
 
     for (TreeItem child : item.getItems()) {
@@ -4659,7 +4666,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
         ensureFolderLoaded(item);
       }
 
-      item.setExpanded(wasExpanded);
+      FolderTreeIcons.setExpanded(item, wasExpanded);
       TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, item, 
wasExpanded);
     }
 
@@ -4860,7 +4867,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
     if (tif != null && tif.folder && isDescendant(tif.path, path)) {
       ensureFolderLoaded(item);
-      item.setExpanded(true);
+      FolderTreeIcons.setExpanded(item, true);
       TreeMemory.getInstance().storeExpanded(FILE_EXPLORER_TREE, item, true);
       for (TreeItem child : item.getItems()) {
         TreeItem found = locateTreeItem(child, path);
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index 8520712f8d..cf005c9c21 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -83,6 +83,7 @@ import org.apache.hop.ui.core.metadata.MetadataEditor;
 import org.apache.hop.ui.core.metadata.MetadataFileType;
 import org.apache.hop.ui.core.metadata.MetadataManager;
 import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.core.widget.FolderTreeIcons;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.NamingSchemeWidgetSupport;
 import org.apache.hop.ui.core.widget.TreeMemory;
@@ -589,6 +590,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     // Remember expand/collapse within the session (shared TreeMemory, keyed 
by stable node ids).
     tree.addListener(SWT.Expand, e -> recordTreeState((TreeItem) e.item, 
true));
     tree.addListener(SWT.Collapse, e -> recordTreeState((TreeItem) e.item, 
false));
+    FolderTreeIcons.install(tree);
 
     // Drag and drop: reorganize within tree (same type only) and drag to 
canvas to open
     createTreeDragSource(tree);
@@ -787,7 +789,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
       onUnknownMetadataDetails();
     } else {
       // Expand/Collapse category
-      treeItem.setExpanded(!treeItem.getExpanded());
+      FolderTreeIcons.setExpanded(treeItem, !treeItem.getExpanded());
     }
   }
 
@@ -983,7 +985,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   public void selectType(String key) {
     TreeItem typeItem = findTypeItem(key);
     if (typeItem != null) {
-      typeItem.setExpanded(true);
+      FolderTreeIcons.setExpanded(typeItem, true);
       tree.setSelection(typeItem);
       tree.showSelection();
       updateSelection();
@@ -3160,7 +3162,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     if (path != null) {
       if (!Utils.isEmpty(currentSearchFilter)) {
         // While searching, expand everything so matches are visible (not 
recorded as a choice).
-        item.setExpanded(true);
+        FolderTreeIcons.setExpanded(item, true);
       } else {
         // Categories (including the "Unknown" one) expand by default; types 
and folders collapse
         // by default. Seed each default-expanded node once per session so the 
default holds until
@@ -3169,7 +3171,8 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         if (defaultExpanded && treeStateSeeded.add(String.join("\0", path))) {
           TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE, 
path, true);
         }
-        
item.setExpanded(TreeMemory.getInstance().isExpanded(METADATA_PERSPECTIVE_TREE, 
path));
+        FolderTreeIcons.setExpanded(
+            item, 
TreeMemory.getInstance().isExpanded(METADATA_PERSPECTIVE_TREE, path));
       }
     }
     for (TreeItem child : item.getItems()) {
@@ -3240,7 +3243,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   /** Recursively expand or collapse a tree item and all its children */
   private void expandTreeItem(TreeItem item, boolean expand) {
-    item.setExpanded(expand);
+    FolderTreeIcons.setExpanded(item, expand);
     for (TreeItem child : item.getItems()) {
       expandTreeItem(child, expand);
     }
diff --git a/ui/src/main/resources/ui/images/folder-add.svg 
b/ui/src/main/resources/ui/images/folder-add.svg
index bc557bff08..eaea2a5969 100644
--- a/ui/src/main/resources/ui/images/folder-add.svg
+++ b/ui/src/main/resources/ui/images/folder-add.svg
@@ -1,6 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24">
-    <path
-            d="M 9.8,3 12,5.2500001 h 8.8 A 2.2,2.2500001 0 0 1 23,7.5000002 V 
18.750001 A 2.2,2.2500001 0 0 1 20.8,21 H 3.2 C 1.979,21 1,19.9875 1,18.750001 
V 5.2500001 C 1,4.0012501 1.979,3 3.2,3 h 6.6 m 5.5,5.6250003 V 12 H 12 v 
2.249999 h 3.3 V 17.625 h 2.2 v -3.375001 h 3.3 V 12 H 17.5 V 8.6250003 Z"
-            style="fill:#0e3a5a;fill-opacity:1;stroke-width:1.11245"/>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+    <path fill="#0e3a5a" d="M20 6h-8l-2-2H4c-1.11 0-1.99.89-1.99 2L2 18c0 
1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-1 
8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z"/>
 </svg>
diff --git a/ui/src/main/resources/ui/images/folder-open.svg 
b/ui/src/main/resources/ui/images/folder-open.svg
new file mode 100644
index 0000000000..adb4e9868f
--- /dev/null
+++ b/ui/src/main/resources/ui/images/folder-open.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+    <path fill="#0e3a5a" d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 
2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z"/>
+</svg>
diff --git a/ui/src/main/resources/ui/images/folder.svg 
b/ui/src/main/resources/ui/images/folder.svg
index 8b3e6b2cd8..a176cd8dfc 100644
--- a/ui/src/main/resources/ui/images/folder.svg
+++ b/ui/src/main/resources/ui/images/folder.svg
@@ -1,10 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24">
-    <path
-            d="M0 0h24v24H0z"
-            fill="none"
-    />
-    <path
-            d="m 20.799999,5.2499999 h -8.8 L 9.8,3 H 3.2 C 1.99,3 
1.0110001,4.0124999 1.0110001,5.2499999 L 1,18.749999 C 1,19.987499 1.99,21 
3.2,21 H 20.799999 C 22.01,21 23,19.987499 23,18.749999 V 7.5000001 C 23,6.2625 
22.01,5.2499999 20.799999,5.2499999 Z m 0,13.4999991 H 3.2 V 7.5000001 h 
17.599999 z"
-            style="fill:#0e3a5a;fill-opacity:1;stroke-width:1.11243"/>
+<svg xmlns="http://www.w3.org/2000/svg"; width="24" height="24" viewBox="0 0 24 
24">
+    <path fill="#0e3a5a" d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 
2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
 </svg>

Reply via email to