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 5923e312dd Issue #7114 : Add optional Spoon-style palette tree to 
pipeline and workflow graphs (#8044)
5923e312dd is described below

commit 5923e312dd498c2f93d9c79fecc63ab42afa7ec3
Author: Matt Casters <[email protected]>
AuthorDate: Fri Aug 21 11:13:49 2026 +0200

    Issue #7114 : Add optional Spoon-style palette tree to pipeline and 
workflow graphs (#8044)
---
 .../ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc   |  15 +
 .../hop-gui/perspective-data-orchestration.adoc    |   2 +
 .../org/apache/hop/ui/core/bus/HopGuiEvents.java   |   6 +
 .../apache/hop/ui/core/dialog/ContextDialog.java   |   3 +
 .../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java |  27 +-
 .../ui/hopgui/context/ContextDialogPlacement.java  |  32 +-
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  | 219 ++++++++-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  | 192 +++++++-
 .../apache/hop/ui/hopgui/palette/GraphPalette.java |  77 ++++
 .../hop/ui/hopgui/palette/GraphPaletteModel.java   | 217 +++++++++
 .../hop/ui/hopgui/palette/GraphPaletteTree.java    | 489 +++++++++++++++++++++
 .../hop/ui/hopgui/palette/IGraphPaletteHost.java   |  51 +++
 .../palette/messages/messages_en_US.properties     |  26 ++
 .../apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java |  50 +++
 .../hopgui/context/ContextDialogPlacementTest.java |  61 +++
 .../ui/hopgui/palette/GraphPaletteModelTest.java   | 137 ++++++
 .../hop/ui/hopgui/palette/GraphPaletteTest.java    |  59 +++
 17 files changed, 1621 insertions(+), 42 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
index 8f6dbbdf4c..7a4fe0b706 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
@@ -55,6 +55,21 @@ Hover over an item to see its description and these 
shortcuts in the tooltip.
 
 TIP: Dropping a new transform or action on top of an existing hop can split 
that hop (same confirmation dialog as when you move an icon onto a hop).
 
+[[palette-tree]]
+== Palette tree
+
+Pipeline and workflow graphs also have an optional Spoon-style palette tree on 
the left of the canvas.
+
+* Use the **palette** toolbar button on the graph to show or hide the tree. It 
is hidden by default; the choice is remembered across restarts.
+* Categories (with **Favorites** at the top when you have any) list every 
transform or action, each with a small icon. Hover for the description.
+* Filter the tree from the text field above it. **Expand all**, **Collapse 
all**, and **Clear filter** sit on the toolbar under the filter.
+* **Drag** an item onto the canvas to place it. **Shift-drag** also creates a 
hop from the selected transform/action (or the last one in the graph).
+* **Double-click** (or press Enter) places the item at the last canvas click, 
or at the center of the visible canvas.
+* **Shift-double-click** adds the item you clicked (not the canvas selection), 
hops from the selected or last chained transform/action, places it to the 
right, and selects it so the next Shift-double-click continues the chain.
+* **ALT-Click** (Option-Click on macOS) or the tree context menu adds or 
removes a favorite.
+
+The canvas popup dialog described above stays available.
+
 == Creating Items
 
 When you create a new item, the dialog will show you a list of metadata items 
that can be created with a single click of a button.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
index 933e686088..78bb5417b1 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
@@ -31,6 +31,8 @@ This is where xref:pipeline/pipelines.adoc[pipelines] and 
xref:workflow/workflow
 
 Pipelines and workflow files can be opened or created. At that point they 
appear in new tabs in this perspective.
 
+An optional Spoon-style palette tree of transforms or actions can be shown on 
the left of the canvas with the palette button on the graph toolbar (hidden by 
default). See xref:hop-gui/hop-gui-popup-dialog.adoc#palette-tree[the palette 
tree] in the popup dialog page.
+
 For more information on how to get started with pipelines, see the 
xref:getting-started/hop-gui-pipelines.adoc[Pipelines getting started guide].
 
 For more information on how to get started with workflow, see the 
xref:getting-started/hop-gui-workflows.adoc[Workflows getting started guide].
diff --git a/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java 
b/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
index be1ec30134..b5b3cbadef 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
@@ -46,5 +46,11 @@ public enum HopGuiEvents {
   ProjectDeleted,
   ProjectActivated,
   ProjectDeactivated,
+
+  /** A transform or workflow-action favorite was added or removed. */
+  FavoritesChanged,
+
+  /** The Spoon-style graph palette tree was shown or hidden. */
+  PaletteTreeVisibilityChanged,
   ;
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index b908d946a1..d7a77da623 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -49,9 +49,11 @@ 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.OsHelper;
+import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
@@ -861,6 +863,7 @@ public class ContextDialog extends Dialog {
                   BaseMessages.getString(PKG, 
"ContextDialog.SaveConfig.Error.Dialog.Message"),
                   e);
             }
+            GraphPalette.fireFavoritesChanged(HopGui.getInstance());
             refreshActionsFromSupplier();
             return;
           }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
index 7dfebf614c..f3e8bd8cae 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
@@ -425,8 +425,12 @@ public class HopGuiKeyHandler extends KeyAdapter {
 
   /**
    * Keys that text-like widgets must handle themselves: 
copy/cut/paste/select-all,
-   * delete/backspace, and caret / selection navigation (arrows, home/end, 
page up/down) without
-   * CTRL/CMD/ALT. Shift alone is allowed so Shift+Arrow selection stays in 
the widget.
+   * delete/backspace, caret / selection navigation (arrows, home/end, page 
up/down) without
+   * CTRL/CMD/ALT, and unmodified printable characters (including space).
+   *
+   * <p>Graph shortcuts such as Space (output fields) and {@code z} (open 
referenced object) must
+   * not steal those keys from filter and search fields. App shortcuts with 
CTRL/CMD/ALT (e.g.
+   * Ctrl+S) still run.
    */
   private static boolean isNativeTextEditingKey(KeyEvent event) {
     if ((event.stateMask & (SWT.CONTROL | SWT.COMMAND)) != 0) {
@@ -438,7 +442,24 @@ public class HopGuiKeyHandler extends KeyAdapter {
     if (event.keyCode == SWT.DEL || event.character == SWT.BS) {
       return true;
     }
-    return isCaretNavigationKey(event);
+    if (isCaretNavigationKey(event)) {
+      return true;
+    }
+    return isUnmodifiedPrintableCharacter(event);
+  }
+
+  /**
+   * Space, letters and punctuation with no CTRL/CMD/ALT. Shift may be held 
for capitals. SWT
+   * reports space as {@link SWT#SPACE} and/or {@code character == ' '}.
+   */
+  private static boolean isUnmodifiedPrintableCharacter(KeyEvent event) {
+    if ((event.stateMask & (SWT.CONTROL | SWT.COMMAND | SWT.ALT)) != 0) {
+      return false;
+    }
+    if (event.keyCode == SWT.SPACE || event.character == ' ') {
+      return true;
+    }
+    return event.character >= 32 && event.character != SWT.DEL;
   }
 
   /**
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
index 3d91be0fe8..194ad9b86a 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
@@ -30,6 +30,12 @@ public final class ContextDialogPlacement {
   /** TextTransfer payload prefix so drop targets ignore unrelated text. */
   public static final String TRANSFER_PREFIX = "hop-context-placement:";
 
+  /**
+   * Same as {@link #TRANSFER_PREFIX} plus a chain marker: the drop target 
hops from the selected
+   * (or last) transform/action to the newly placed one (Shift-drag from the 
palette tree).
+   */
+  public static final String TRANSFER_PREFIX_CHAIN = 
"hop-context-placement-chain:";
+
   private ContextDialogPlacement() {
     // utility
   }
@@ -38,21 +44,39 @@ public final class ContextDialogPlacement {
     if (action == null || StringUtils.isEmpty(action.getId())) {
       return null;
     }
-    return TRANSFER_PREFIX + action.getId();
+    return encode(action.getId(), false);
+  }
+
+  public static String encode(String actionId, boolean chainHop) {
+    if (StringUtils.isEmpty(actionId)) {
+      return null;
+    }
+    return (chainHop ? TRANSFER_PREFIX_CHAIN : TRANSFER_PREFIX) + actionId;
   }
 
   public static boolean isPlacementPayload(Object data) {
-    return data instanceof String s && s.startsWith(TRANSFER_PREFIX);
+    return data instanceof String s
+        && (s.startsWith(TRANSFER_PREFIX_CHAIN) || 
s.startsWith(TRANSFER_PREFIX));
+  }
+
+  public static boolean isChainPayload(Object data) {
+    return data instanceof String s && s.startsWith(TRANSFER_PREFIX_CHAIN);
   }
 
   /**
    * @return the GuiAction id embedded in a placement payload, or null if not 
a placement payload
    */
   public static String decodeActionId(Object data) {
-    if (!isPlacementPayload(data)) {
+    if (!(data instanceof String s)) {
       return null;
     }
-    return ((String) data).substring(TRANSFER_PREFIX.length());
+    if (s.startsWith(TRANSFER_PREFIX_CHAIN)) {
+      return s.substring(TRANSFER_PREFIX_CHAIN.length());
+    }
+    if (s.startsWith(TRANSFER_PREFIX)) {
+      return s.substring(TRANSFER_PREFIX.length());
+    }
+    return null;
   }
 
   /**
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index eda1c64c9c..a8a00c0030 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -50,6 +50,7 @@ import org.apache.hop.core.Props;
 import org.apache.hop.core.SwtUniversalImage;
 import org.apache.hop.core.action.GuiContextAction;
 import org.apache.hop.core.action.GuiContextActionFilter;
+import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopMissingPluginsException;
 import org.apache.hop.core.exception.HopPluginException;
@@ -138,6 +139,7 @@ import org.apache.hop.pipeline.transform.stream.Stream;
 import org.apache.hop.pipeline.transform.stream.StreamIcon;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.ContextDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -196,11 +198,15 @@ import 
org.apache.hop.ui.hopgui.file.shared.HopGuiGraphSnapshotUndo;
 import org.apache.hop.ui.hopgui.file.shared.HopGuiTooltipExtension;
 import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
 import org.apache.hop.ui.hopgui.file.shared.PipelineRowSamplerHelper;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteTree;
+import org.apache.hop.ui.hopgui.palette.IGraphPaletteHost;
 import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
 import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
 import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
+import org.apache.hop.ui.hopgui.shared.SashFormMemory;
 import org.apache.hop.ui.hopgui.shared.SwtGc;
 import org.apache.hop.ui.pipeline.dialog.PipelineDialog;
 import org.apache.hop.ui.util.EnvironmentUtils;
@@ -258,7 +264,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
         IHopFileTypeHandler,
         IGuiRefresher,
         IWebCanvasGraph,
-        ISnapshotUndoSupport {
+        ISnapshotUndoSupport,
+        IGraphPaletteHost {
 
   private static final Class<?> PKG = HopGui.class;
 
@@ -289,6 +296,9 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
   public static final String TOOLBAR_ITEM_AUTO_LAYOUT =
       "HopGuiPipelineGraph-ToolBar-10545-Auto-Layout";
 
+  public static final String TOOLBAR_ITEM_PALETTE_TREE =
+      "HopGuiPipelineGraph-ToolBar-10547-Palette-Tree";
+
   public static final String TOOLBAR_ITEM_DESIGN_ENGINE =
       "HopGuiPipelineGraph-ToolBar-10550-Design-Engine";
 
@@ -419,6 +429,10 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
   private final List<AreaOwner> areaOwners;
 
+  private final SashForm paletteSash;
+
+  private GraphPaletteTree paletteTree;
+
   private final SashForm sashForm;
 
   public CTabFolder extraViewTabFolder;
@@ -580,20 +594,24 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     fdMainComposite.bottom = new FormAttachment(100, 0);
     mainComposite.setLayoutData(fdMainComposite);
 
-    // To allow for a splitter later on, we will add the splitter here...
+    // Outer sash: Spoon-style palette tree on the left, graph (+ extra view) 
on the right.
     //
+    paletteSash = new SashForm(mainComposite, SWT.HORIZONTAL);
+    FormData fdPaletteSash = new FormData();
+    fdPaletteSash.left = new FormAttachment(0, 0);
+    fdPaletteSash.top = new FormAttachment(0, 0);
+    fdPaletteSash.right = new FormAttachment(100, 0);
+    fdPaletteSash.bottom = new FormAttachment(100, 0);
+    paletteSash.setLayoutData(fdPaletteSash);
+
+    paletteTree = new GraphPaletteTree(paletteSash, this);
+
     sashForm =
         new SashForm(
-            mainComposite,
+            paletteSash,
             PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
                 ? SWT.VERTICAL
                 : SWT.HORIZONTAL);
-    FormData fdSashForm = new FormData();
-    fdSashForm.left = new FormAttachment(0, 0);
-    fdSashForm.top = new FormAttachment(0, 0);
-    fdSashForm.right = new FormAttachment(100, 0);
-    fdSashForm.bottom = new FormAttachment(100, 0);
-    sashForm.setLayoutData(fdSashForm);
 
     // Add a canvas below it, use up all space initially
     //
@@ -622,6 +640,15 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     canvas.setLayoutData(fdCanvas);
 
     sashForm.setWeights(100);
+    SashFormMemory.persist(
+        paletteSash, GraphPalette.SASH_AUDIT_KEY, 
GraphPalette.DEFAULT_SASH_WEIGHTS);
+    applyPaletteVisibility();
+    hopGui
+        .getEventsHandler()
+        .addEventListener(
+            paletteListenerId(),
+            e -> applyPaletteVisibility(),
+            HopGuiEvents.PaletteTreeVisibilityChanged.name());
 
     toolTip = new HopToolTip(getShell());
     toolTip.setAutoHide(true);
@@ -667,10 +694,9 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       canvas.addMouseMoveListener(this);
       canvas.addMouseTrackListener(this);
       canvas.addMouseWheelListener(this::mouseScrolled);
-    } else {
-      // Hop Web: accept create actions dragged from the context dialog 
(HTML5/SWT DnD).
-      installContextDialogPlacementDropTarget();
     }
+    // Palette tree (and Hop Web context dialog) place items via SWT DnD.
+    installContextDialogPlacementDropTarget();
 
     setBackground(GuiResource.getInstance().getColorBackground());
 
@@ -693,6 +719,7 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
 
   @Override
   public void dispose() {
+    hopGui.getEventsHandler().removeEventListeners(paletteListenerId());
     if (EnvironmentUtils.getInstance().isWeb() && canvas != null && 
!canvas.isDisposed()) {
       CanvasSvgFacade.unregisterCanvas(canvas);
     }
@@ -1707,7 +1734,9 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
             }
             // DropTargetEvent x/y are relative to the Display in SWT/RAP — 
convert to canvas.
             org.eclipse.swt.graphics.Point canvasPos = 
canvas.toControl(event.x, event.y);
-            boolean placed = placeFromContextDialogActionId(actionId, 
canvasPos.x, canvasPos.y);
+            boolean chainHop = 
ContextDialogPlacement.isChainPayload(event.data);
+            boolean placed =
+                placeFromContextDialogActionId(actionId, canvasPos.x, 
canvasPos.y, chainHop);
             if (placed) {
               ContextDialogPlacement.markDropCompletedOnActiveDialog();
               event.detail = DND.DROP_COPY;
@@ -1735,6 +1764,11 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
    * @return true if a transform was created
    */
   public boolean placeFromContextDialogActionId(String actionId, int canvasX, 
int canvasY) {
+    return placeFromContextDialogActionId(actionId, canvasX, canvasY, false);
+  }
+
+  public boolean placeFromContextDialogActionId(
+      String actionId, int canvasX, int canvasY, boolean chainHop) {
     GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromId(actionId);
     if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.TRANSFORM) {
       return false;
@@ -1742,14 +1776,99 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     if (canvas == null || canvas.isDisposed()) {
       return false;
     }
+    return placeTransformFromPalette(
+        resolved.pluginId(), placementLocationFromCanvas(canvasX, canvasY), 
chainHop);
+  }
+
+  @Override
+  public String getPaletteHostId() {
+    return getId();
+  }
+
+  @Override
+  public GuiActionFavorites.Kind getPaletteKind() {
+    return GuiActionFavorites.Kind.TRANSFORM;
+  }
+
+  @Override
+  public boolean placePaletteAction(String actionId, Point graphLocation, 
boolean chainHop) {
+    GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromId(actionId);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.TRANSFORM) {
+      return false;
+    }
+    // Keep a null location: placeTransformFromPalette then sits the item to 
the right of the
+    // chain source. Filling in lastClick here is what put Shift-double-click 
at (0,0).
+    return placeTransformFromPalette(resolved.pluginId(), graphLocation, 
chainHop);
+  }
+
+  @Override
+  public Point getPaletteDropLocation() {
+    if (lastClick != null) {
+      return new Point(lastClick.x, lastClick.y);
+    }
+    if (canvas == null || canvas.isDisposed()) {
+      return new Point(50, 50);
+    }
+    org.eclipse.swt.graphics.Rectangle client = canvas.getClientArea();
+    Point real = screen2real(Math.max(client.width / 2, 0), 
Math.max(client.height / 2, 0));
+    int half = Math.max(iconSize / 2, 1);
+    return new Point(Math.max(0, real.x - half), Math.max(0, real.y - half));
+  }
+
+  @Override
+  public void applyPaletteVisibility() {
+    if (paletteSash == null || paletteSash.isDisposed()) {
+      return;
+    }
+    boolean visible = GraphPalette.isVisible();
+    if (visible) {
+      if (paletteTree != null && !paletteTree.isDisposed()) {
+        paletteTree.ensurePopulated();
+      }
+      paletteSash.setMaximizedControl(null);
+      SashFormMemory.restore(
+          paletteSash, GraphPalette.SASH_AUDIT_KEY, 
GraphPalette.DEFAULT_SASH_WEIGHTS);
+    } else {
+      paletteSash.setMaximizedControl(sashForm);
+    }
+    updatePaletteToolbarButton(visible);
+  }
+
+  @Override
+  public void persistFavoritesChange() {
+    try {
+      HopConfig.getInstance().saveToFile();
+    } catch (Exception e) {
+      log.logError("Error saving favorites", e);
+    }
+    GraphPalette.fireFavoritesChanged(hopGui);
+  }
+
+  private String paletteListenerId() {
+    return "HopGuiPipelineGraph-Palette-" + getId();
+  }
 
-    Point location = placementLocationFromCanvas(canvasX, canvasY);
+  private void updatePaletteToolbarButton(boolean visible) {
+    if (toolBarWidgets == null) {
+      return;
+    }
+    toolBarWidgets.setToolbarItemToolTip(
+        TOOLBAR_ITEM_PALETTE_TREE,
+        BaseMessages.getString(
+            org.apache.hop.ui.hopgui.palette.GraphPaletteTree.class,
+            visible ? "GraphPalette.Toolbar.Hide.Tooltip" : 
"GraphPalette.Toolbar.Show.Tooltip"));
+  }
+
+  private boolean placeTransformFromPalette(String pluginId, Point location, 
boolean chainHop) {
+    TransformMeta chainSource = chainHop ? resolveChainSource() : null;
+    if (location == null) {
+      location = chainHop ? locationAfter(chainSource) : 
getPaletteDropLocation();
+    }
     int half = Math.max(iconSize / 2, 1);
-    String pluginName = resolved.pluginId();
+    String pluginName = pluginId;
     try {
       IPlugin plugin =
-          PluginRegistry.getInstance()
-              .findPluginWithId(TransformPluginType.class, 
resolved.pluginId());
+          
PluginRegistry.getInstance().findPluginWithId(TransformPluginType.class, 
pluginId);
       if (plugin != null && plugin.getName() != null) {
         pluginName = plugin.getName();
       }
@@ -1759,21 +1878,30 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
     TransformMeta transformMeta =
         pipelineTransformDelegate.newTransform(
-            pipelineMeta, resolved.pluginId(), pluginName, pluginName, false, 
true, location);
+            pipelineMeta, pluginId, pluginName, pluginName, false, true, 
location);
     if (transformMeta == null) {
       return false;
     }
 
     PipelineHopMeta hop = findPipelineHop(location.x + half, location.y + 
half, transformMeta);
-    if (hop != null
-        && pipelineMeta.findPipelineHop(transformMeta, hop.getFromTransform()) 
== null
-        && pipelineMeta.findPipelineHop(transformMeta, hop.getToTransform()) 
== null
-        && pipelineMeta.findPipelineHop(hop.getToTransform(), transformMeta) 
== null
-        && pipelineMeta.findPipelineHop(hop.getFromTransform(), transformMeta) 
== null) {
+    boolean split =
+        hop != null
+            && pipelineMeta.findPipelineHop(transformMeta, 
hop.getFromTransform()) == null
+            && pipelineMeta.findPipelineHop(transformMeta, 
hop.getToTransform()) == null
+            && pipelineMeta.findPipelineHop(hop.getToTransform(), 
transformMeta) == null
+            && pipelineMeta.findPipelineHop(hop.getFromTransform(), 
transformMeta) == null;
+    if (split) {
       currentTransform = transformMeta;
       splitHop(hop);
+    } else if (chainHop
+        && chainSource != null
+        && chainSource != transformMeta
+        && pipelineMeta.findPipelineHop(chainSource, transformMeta) == null) {
+      pipelineHopDelegate.newHop(pipelineMeta, new 
PipelineHopMeta(chainSource, transformMeta));
     }
 
+    lastChained = transformMeta;
+    lastClick = new Point(location.x, location.y);
     pipelineMeta.unselectAll();
     transformMeta.setSelected(true);
     avoidContextDialog = true;
@@ -1782,6 +1910,36 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return true;
   }
 
+  private TransformMeta resolveChainSource() {
+    if (lastChained != null && 
pipelineMeta.findTransform(lastChained.getName()) == null) {
+      lastChained = null;
+    }
+    List<TransformMeta> selected = pipelineMeta.getSelectedTransforms();
+    if (selected != null && selected.size() == 1) {
+      return selected.get(0);
+    }
+    if (lastChained != null) {
+      return lastChained;
+    }
+    int n = pipelineMeta.nrTransforms();
+    return n > 0 ? pipelineMeta.getTransform(n - 1) : null;
+  }
+
+  /**
+   * Place the next chained transform to the right of {@code source}. When 
hopping from an existing
+   * transform the new icon sits {@link GraphPalette#CHAIN_OFFSET_X} further 
right.
+   */
+  private Point locationAfter(TransformMeta source) {
+    if (source == null) {
+      Point p = pipelineMeta.getMaximum();
+      p.x -= 100;
+      p.x += 200;
+      return p;
+    }
+    Point loc = source.getLocation();
+    return new Point(loc.x + GraphPalette.CHAIN_OFFSET_X, loc.y);
+  }
+
   /**
    * Start a placement drag from the context dialog (issue #3111). The dialog 
has already closed. A
    * ghost transform is created when the pointer first moves over the canvas 
so the icon is visible
@@ -2861,6 +3019,20 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     String selected = combo.getText();
     String engineId = 
PaletteEngineFilter.getPipelineEngineIdForLabel(selected);
     PaletteEngineFilter.setPipelineDesignEngineId(engineId);
+    if (paletteTree != null && !paletteTree.isDisposed()) {
+      paletteTree.refresh();
+    }
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_PALETTE_TREE,
+      toolTip = 
"i18n:org.apache.hop.ui.hopgui.palette:GraphPalette.Toolbar.Show.Tooltip",
+      image = "ui/images/palette.svg",
+      separator = true)
+  public void togglePaletteTree() {
+    GraphPalette.setVisible(!GraphPalette.isVisible());
+    GraphPalette.fireVisibilityChanged(hopGui);
   }
 
   /** Combo values for {@link #TOOLBAR_ITEM_DESIGN_ENGINE} — referenced by 
reflection. */
@@ -6378,6 +6550,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     updateGui();
   }
 
+  @Override
   public HopGui getHopGui() {
     return hopGui;
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 8b26c9e414..d276a92fb9 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -45,6 +45,7 @@ import org.apache.hop.core.ResultFile;
 import org.apache.hop.core.RowMetaAndData;
 import org.apache.hop.core.action.GuiContextAction;
 import org.apache.hop.core.action.GuiContextActionFilter;
+import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopPluginException;
 import org.apache.hop.core.exception.HopXmlException;
@@ -102,6 +103,7 @@ import 
org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.pipeline.PipelinePainter;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.ContextDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -150,11 +152,15 @@ import 
org.apache.hop.ui.hopgui.file.workflow.delegates.HopGuiWorkflowLogDelegat
 import 
org.apache.hop.ui.hopgui.file.workflow.delegates.HopGuiWorkflowRunDelegate;
 import 
org.apache.hop.ui.hopgui.file.workflow.delegates.HopGuiWorkflowUndoDelegate;
 import 
org.apache.hop.ui.hopgui.file.workflow.extension.HopGuiWorkflowGraphExtension;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteTree;
+import org.apache.hop.ui.hopgui.palette.IGraphPaletteHost;
 import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
 import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
 import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
+import org.apache.hop.ui.hopgui.shared.SashFormMemory;
 import org.apache.hop.ui.hopgui.shared.SwtGc;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.apache.hop.ui.util.HelpUtils;
@@ -220,6 +226,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
         IHopFileTypeHandler,
         IGuiRefresher,
         IWebCanvasGraph,
+        IGraphPaletteHost,
         ISnapshotUndoSupport {
 
   private static final Class<?> PKG = HopGuiWorkflowGraph.class;
@@ -252,6 +259,9 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
   public static final String TOOLBAR_ITEM_DESIGN_ENGINE =
       "HopGuiWorkflowGraph-ToolBar-10550-Design-Engine";
 
+  public static final String TOOLBAR_ITEM_PALETTE_TREE =
+      "HopGuiWorkflowGraph-ToolBar-10537-Palette-Tree";
+
   public static final String TOOLBAR_ITEM_EDIT_WORKFLOW =
       "HopGuiWorkflowGraph-ToolBar-10450-EditWorkflow";
 
@@ -368,6 +378,10 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
   @Getter private Object canvasZoomHandler; // For web/RAP zoom handling
 
+  private SashForm paletteSash;
+
+  private GraphPaletteTree paletteTree;
+
   private SashForm sashForm;
 
   public CTabFolder extraViewTabFolder;
@@ -490,11 +504,14 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     fdMainComposite.bottom = new FormAttachment(100, 0);
     mainComposite.setLayoutData(fdMainComposite);
 
-    // To allow for a splitter later on, we will add the splitter here...
+    // Outer sash: Spoon-style palette tree on the left, graph (+ extra view) 
on the right.
     //
+    paletteSash = new SashForm(mainComposite, SWT.HORIZONTAL);
+    paletteTree = new GraphPaletteTree(paletteSash, this);
+
     sashForm =
         new SashForm(
-            mainComposite,
+            paletteSash,
             PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
                 ? SWT.VERTICAL
                 : SWT.HORIZONTAL);
@@ -526,6 +543,15 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     canvas.setLayoutData(fdCanvas);
 
     sashForm.setWeights(100);
+    SashFormMemory.persist(
+        paletteSash, GraphPalette.SASH_AUDIT_KEY, 
GraphPalette.DEFAULT_SASH_WEIGHTS);
+    applyPaletteVisibility();
+    hopGui
+        .getEventsHandler()
+        .addEventListener(
+            paletteListenerId(),
+            e -> applyPaletteVisibility(),
+            HopGuiEvents.PaletteTreeVisibilityChanged.name());
 
     toolTip = new HopToolTip(getShell());
     toolTip.setAutoHide(true);
@@ -572,10 +598,9 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       canvas.addMouseMoveListener(this);
       canvas.addMouseTrackListener(this);
       canvas.addMouseWheelListener(this::mouseScrolled);
-    } else {
-      // Hop Web: accept create actions dragged from the context dialog 
(HTML5/SWT DnD).
-      installContextDialogPlacementDropTarget();
     }
+    // Palette tree (and Hop Web context dialog) place items via SWT DnD.
+    installContextDialogPlacementDropTarget();
 
     hopGui.replaceKeyboardShortcutListeners(this);
     setBackground(GuiResource.getInstance().getColorBackground());
@@ -593,6 +618,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
 
   @Override
   public void dispose() {
+    hopGui.getEventsHandler().removeEventListeners(paletteListenerId());
     if (EnvironmentUtils.getInstance().isWeb() && canvas != null && 
!canvas.isDisposed()) {
       CanvasSvgFacade.unregisterCanvas(canvas);
     }
@@ -1452,7 +1478,9 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
             }
             // DropTargetEvent x/y are relative to the Display in SWT/RAP — 
convert to canvas.
             org.eclipse.swt.graphics.Point canvasPos = 
canvas.toControl(event.x, event.y);
-            boolean placed = placeFromContextDialogActionId(actionId, 
canvasPos.x, canvasPos.y);
+            boolean chainHop = 
ContextDialogPlacement.isChainPayload(event.data);
+            boolean placed =
+                placeFromContextDialogActionId(actionId, canvasPos.x, 
canvasPos.y, chainHop);
             if (placed) {
               ContextDialogPlacement.markDropCompletedOnActiveDialog();
               event.detail = DND.DROP_COPY;
@@ -1480,6 +1508,11 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
    * @return true if an action was created
    */
   public boolean placeFromContextDialogActionId(String actionId, int canvasX, 
int canvasY) {
+    return placeFromContextDialogActionId(actionId, canvasX, canvasY, false);
+  }
+
+  public boolean placeFromContextDialogActionId(
+      String actionId, int canvasX, int canvasY, boolean chainHop) {
     GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromId(actionId);
     if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
       return false;
@@ -1487,14 +1520,99 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     if (canvas == null || canvas.isDisposed()) {
       return false;
     }
+    return placeActionFromPalette(
+        resolved.pluginId(), placementLocationFromCanvas(canvasX, canvasY), 
chainHop);
+  }
 
-    Point location = placementLocationFromCanvas(canvasX, canvasY);
+  @Override
+  public String getPaletteHostId() {
+    return getId();
+  }
+
+  @Override
+  public GuiActionFavorites.Kind getPaletteKind() {
+    return GuiActionFavorites.Kind.WORKFLOW_ACTION;
+  }
+
+  @Override
+  public boolean placePaletteAction(String actionId, Point graphLocation, 
boolean chainHop) {
+    GuiActionFavorites.KindAndPluginId resolved = 
GuiActionFavorites.resolveFromId(actionId);
+    if (resolved == null || resolved.kind() != 
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
+      return false;
+    }
+    // Keep a null location: placeActionFromPalette then sits the item to the 
right of the
+    // chain source. Filling in lastClick here is what put Shift-double-click 
at (0,0).
+    return placeActionFromPalette(resolved.pluginId(), graphLocation, 
chainHop);
+  }
+
+  @Override
+  public Point getPaletteDropLocation() {
+    if (lastClick != null) {
+      return new Point(lastClick.x, lastClick.y);
+    }
+    if (canvas == null || canvas.isDisposed()) {
+      return new Point(50, 50);
+    }
+    org.eclipse.swt.graphics.Rectangle client = canvas.getClientArea();
+    Point real = screen2real(Math.max(client.width / 2, 0), 
Math.max(client.height / 2, 0));
     int half = Math.max(iconSize / 2, 1);
-    String pluginName = resolved.pluginId();
+    return new Point(Math.max(0, real.x - half), Math.max(0, real.y - half));
+  }
+
+  @Override
+  public void applyPaletteVisibility() {
+    if (paletteSash == null || paletteSash.isDisposed()) {
+      return;
+    }
+    boolean visible = GraphPalette.isVisible();
+    if (visible) {
+      if (paletteTree != null && !paletteTree.isDisposed()) {
+        paletteTree.ensurePopulated();
+      }
+      paletteSash.setMaximizedControl(null);
+      SashFormMemory.restore(
+          paletteSash, GraphPalette.SASH_AUDIT_KEY, 
GraphPalette.DEFAULT_SASH_WEIGHTS);
+    } else {
+      paletteSash.setMaximizedControl(sashForm);
+    }
+    updatePaletteToolbarButton(visible);
+  }
+
+  @Override
+  public void persistFavoritesChange() {
+    try {
+      HopConfig.getInstance().saveToFile();
+    } catch (Exception e) {
+      log.logError("Error saving favorites", e);
+    }
+    GraphPalette.fireFavoritesChanged(hopGui);
+  }
+
+  private String paletteListenerId() {
+    return "HopGuiWorkflowGraph-Palette-" + getId();
+  }
+
+  private void updatePaletteToolbarButton(boolean visible) {
+    if (toolBarWidgets == null) {
+      return;
+    }
+    toolBarWidgets.setToolbarItemToolTip(
+        TOOLBAR_ITEM_PALETTE_TREE,
+        BaseMessages.getString(
+            org.apache.hop.ui.hopgui.palette.GraphPaletteTree.class,
+            visible ? "GraphPalette.Toolbar.Hide.Tooltip" : 
"GraphPalette.Toolbar.Show.Tooltip"));
+  }
+
+  private boolean placeActionFromPalette(String pluginId, Point location, 
boolean chainHop) {
+    ActionMeta chainSource = chainHop ? resolveChainSource() : null;
+    if (location == null) {
+      location = chainHop ? locationAfter(chainSource) : 
getPaletteDropLocation();
+    }
+    int half = Math.max(iconSize / 2, 1);
+    String pluginName = pluginId;
     try {
       IPlugin plugin =
-          PluginRegistry.getInstance()
-              .findPluginWithId(ActionPluginType.class, resolved.pluginId());
+          
PluginRegistry.getInstance().findPluginWithId(ActionPluginType.class, pluginId);
       if (plugin != null && plugin.getName() != null) {
         pluginName = plugin.getName();
       }
@@ -1503,8 +1621,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
 
     ActionMeta actionMeta =
-        workflowActionDelegate.newAction(
-            workflowMeta, resolved.pluginId(), pluginName, false, location);
+        workflowActionDelegate.newAction(workflowMeta, pluginId, pluginName, 
false, location);
     if (actionMeta == null) {
       return false;
     }
@@ -1534,8 +1651,15 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       if ((id & 0xFF) == 0) {
         workflowActionDelegate.insertAction(workflowMeta, hop, actionMeta);
       }
+    } else if (chainHop
+        && chainSource != null
+        && chainSource != actionMeta
+        && workflowMeta.findWorkflowHop(chainSource, actionMeta) == null) {
+      workflowHopDelegate.newHop(workflowMeta, chainSource, actionMeta);
     }
 
+    lastChained = actionMeta;
+    lastClick = new Point(location.x, location.y);
     workflowMeta.unselectAll();
     actionMeta.setSelected(true);
     avoidContextDialog = true;
@@ -1543,6 +1667,36 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     return true;
   }
 
+  private ActionMeta resolveChainSource() {
+    if (lastChained != null && workflowMeta.findAction(lastChained.getName()) 
== null) {
+      lastChained = null;
+    }
+    List<ActionMeta> selected = workflowMeta.getSelectedActions();
+    if (selected != null && selected.size() == 1) {
+      return selected.get(0);
+    }
+    if (lastChained != null) {
+      return lastChained;
+    }
+    int n = workflowMeta.nrActions();
+    return n > 0 ? workflowMeta.getAction(n - 1) : null;
+  }
+
+  /**
+   * Place the next chained action to the right of {@code source}. When 
hopping from an existing
+   * action the new icon sits {@link GraphPalette#CHAIN_OFFSET_X} further 
right.
+   */
+  private Point locationAfter(ActionMeta source) {
+    if (source == null) {
+      Point p = workflowMeta.getMaximum();
+      p.x -= 100;
+      p.x += 200;
+      return p;
+    }
+    Point loc = source.getLocation();
+    return new Point(loc.x + GraphPalette.CHAIN_OFFSET_X, loc.y);
+  }
+
   /**
    * Start a placement drag from the context dialog (issue #3111). The dialog 
has already closed. A
    * ghost action is created when the pointer first moves over the canvas so 
the icon is visible
@@ -2338,6 +2492,20 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     String selected = combo.getText();
     String engineId = 
PaletteEngineFilter.getWorkflowEngineIdForLabel(selected);
     PaletteEngineFilter.setWorkflowDesignEngineId(engineId);
+    if (paletteTree != null && !paletteTree.isDisposed()) {
+      paletteTree.refresh();
+    }
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_PALETTE_TREE,
+      toolTip = 
"i18n:org.apache.hop.ui.hopgui.palette:GraphPalette.Toolbar.Show.Tooltip",
+      image = "ui/images/palette.svg",
+      separator = true)
+  public void togglePaletteTree() {
+    GraphPalette.setVisible(!GraphPalette.isVisible());
+    GraphPalette.fireVisibilityChanged(hopGui);
   }
 
   /** Combo values for {@link #TOOLBAR_ITEM_DESIGN_ENGINE} — referenced by 
reflection. */
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPalette.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPalette.java
new file mode 100644
index 0000000000..e23c707ffd
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPalette.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.palette;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
+import org.apache.hop.ui.hopgui.HopGui;
+
+/**
+ * Visibility and sash layout for the Spoon-style transform/action palette 
tree (issue #7114).
+ * Hidden by default; the choice is stored in hop-config.json so it applies to 
new pipeline and
+ * workflow tabs.
+ */
+public final class GraphPalette {
+
+  public static final String CONFIG_KEY = "ShowGraphPaletteTree";
+  public static final String SASH_AUDIT_KEY = "graph-palette-tree-width";
+  public static final int[] DEFAULT_SASH_WEIGHTS = {20, 80};
+
+  /** Horizontal offset when Shift-double-click hops from an existing 
transform or action. */
+  public static final int CHAIN_OFFSET_X = 150;
+
+  private GraphPalette() {
+    // utility
+  }
+
+  /** False when the property is missing or not {@code Y} — the palette stays 
hidden by default. */
+  public static boolean isVisible() {
+    return 
"Y".equalsIgnoreCase(Const.NVL(HopConfig.getGuiProperty(CONFIG_KEY), "N"));
+  }
+
+  public static void setVisible(boolean visible) {
+    HopConfig.setGuiProperty(CONFIG_KEY, visible ? "Y" : "N");
+    try {
+      HopConfig.getInstance().saveToFile();
+    } catch (Exception e) {
+      LogChannel.UI.logError("Error saving palette tree visibility", e);
+    }
+  }
+
+  public static void fireFavoritesChanged(HopGui hopGui) {
+    fireEvent(hopGui, HopGuiEvents.FavoritesChanged);
+  }
+
+  public static void fireVisibilityChanged(HopGui hopGui) {
+    fireEvent(hopGui, HopGuiEvents.PaletteTreeVisibilityChanged);
+  }
+
+  private static void fireEvent(HopGui hopGui, HopGuiEvents event) {
+    if (hopGui == null) {
+      return;
+    }
+    try {
+      hopGui.getEventsHandler().fire(null, true, event.name());
+    } catch (HopException e) {
+      LogChannel.UI.logError("Error firing " + event.name(), e);
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModel.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModel.java
new file mode 100644
index 0000000000..6a065fcf5c
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModel.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.hopgui.palette;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.plugins.ActionPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.ui.core.dialog.ContextDialog;
+import org.apache.hop.ui.hopgui.PaletteEngineFilter;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites.Kind;
+
+/**
+ * Categorized list of pipeline transforms or workflow actions for the 
Spoon-style palette tree.
+ * Favorites (when any exist) are always the first category.
+ */
+@Getter
+public final class GraphPaletteModel {
+
+  public record Item(
+      String pluginId,
+      String actionId,
+      String name,
+      String category,
+      String description,
+      String imageFile,
+      List<String> keywords) {
+
+    public Item {
+      keywords = keywords == null ? List.of() : List.copyOf(keywords);
+      name = Const.NVL(name, pluginId);
+      category = Const.NVL(category, ContextDialog.CATEGORY_OTHER);
+      description = Const.NVL(description, "");
+      imageFile = Const.NVL(imageFile, "");
+    }
+
+    /**
+     * Same field weighting as {@link 
org.apache.hop.core.gui.plugin.action.GuiAction#matchScore}.
+     */
+    public double matchScore(SearchMatcher matcher) {
+      if (matcher == null) {
+        return 1.0;
+      }
+      double best = matcher.score(name);
+      best = Math.max(best, 0.9 * matcher.score(description));
+      if (keywords != null) {
+        for (String keyword : keywords) {
+          best = Math.max(best, 0.8 * matcher.score(keyword));
+        }
+      }
+      return Math.max(best, 0.7 * matcher.score(category));
+    }
+  }
+
+  public record Category(String name, List<Item> items) {
+    public Category {
+      items = items == null ? List.of() : List.copyOf(items);
+      name = Const.NVL(name, ContextDialog.CATEGORY_OTHER);
+    }
+  }
+
+  private final List<Category> categories;
+
+  public GraphPaletteModel(List<Category> categories) {
+    this.categories = categories == null ? List.of() : List.copyOf(categories);
+  }
+
+  public static GraphPaletteModel fromPlugins(Kind kind) {
+    PaletteEngineFilter filter =
+        kind == Kind.TRANSFORM
+            ? PaletteEngineFilter.forPipelineDesign()
+            : PaletteEngineFilter.forWorkflowDesign();
+    PluginRegistry registry = PluginRegistry.getInstance();
+    List<IPlugin> plugins =
+        kind == Kind.TRANSFORM
+            ? registry.getPlugins(TransformPluginType.class)
+            : registry.getPlugins(ActionPluginType.class);
+    List<Item> items = new ArrayList<>();
+    for (IPlugin plugin : plugins) {
+      if (!filter.isPluginAllowed(plugin)
+          || plugin.getIds() == null
+          || plugin.getIds().length == 0) {
+        continue;
+      }
+      String pluginId = plugin.getIds()[0];
+      List<String> keywords = new ArrayList<>();
+      if (plugin.getKeywords() != null) {
+        keywords.addAll(Arrays.asList(plugin.getKeywords()));
+      }
+      if (plugin.getEnglishKeywords() != null) {
+        keywords.addAll(Arrays.asList(plugin.getEnglishKeywords()));
+      }
+      if (StringUtils.isNotEmpty(plugin.getCategory())) {
+        keywords.add(plugin.getCategory());
+      }
+      items.add(
+          new Item(
+              pluginId,
+              GuiActionFavorites.createId(kind, pluginId),
+              plugin.getName(),
+              plugin.getCategory(),
+              plugin.getDescription(),
+              plugin.getImageFile(),
+              keywords));
+    }
+    return fromItems(items, GuiActionFavorites.getFavoriteIds(kind), 
favoritesCategoryName());
+  }
+
+  public static String favoritesCategoryName() {
+    return GuiActionFavorites.getFavoritesCategoryName();
+  }
+
+  /**
+   * Group {@code items} into categories. Favorites (ids that still exist in 
{@code items}) come
+   * first, in the stored favorite order. Remaining categories are 
alphabetical; items within a
+   * category are alphabetical by name.
+   */
+  public static GraphPaletteModel fromItems(
+      List<Item> items, List<String> favoriteIds, String 
favoritesCategoryName) {
+    Map<String, Item> byPluginId = new LinkedHashMap<>();
+    Map<String, List<Item>> byCategory = new LinkedHashMap<>();
+    if (items != null) {
+      for (Item item : items) {
+        if (item == null || StringUtils.isEmpty(item.pluginId())) {
+          continue;
+        }
+        byPluginId.put(item.pluginId(), item);
+        byCategory.computeIfAbsent(item.category(), k -> new 
ArrayList<>()).add(item);
+      }
+    }
+
+    List<Category> result = new ArrayList<>();
+    if (favoriteIds != null && !favoriteIds.isEmpty()) {
+      List<Item> favorites = new ArrayList<>();
+      for (String favoriteId : favoriteIds) {
+        Item item = byPluginId.get(favoriteId);
+        if (item != null) {
+          favorites.add(item);
+        }
+      }
+      if (!favorites.isEmpty()) {
+        result.add(new Category(Const.NVL(favoritesCategoryName, "Favorites"), 
favorites));
+      }
+    }
+
+    List<String> categoryNames = new ArrayList<>(byCategory.keySet());
+    categoryNames.sort(String.CASE_INSENSITIVE_ORDER);
+    for (String categoryName : categoryNames) {
+      List<Item> categoryItems = new ArrayList<>(byCategory.get(categoryName));
+      categoryItems.sort(Comparator.comparing(Item::name, 
String.CASE_INSENSITIVE_ORDER));
+      result.add(new Category(categoryName, categoryItems));
+    }
+    return new GraphPaletteModel(result);
+  }
+
+  /**
+   * Filter categories/items with the same matcher as the context dialog. 
Empty categories are
+   * omitted. When {@code text} is blank the full model is returned.
+   */
+  public List<Category> filter(String text) {
+    if (StringUtils.isEmpty(text)) {
+      return categories;
+    }
+    SearchMatcher matcher = new SearchMatcher(text, false, false, true);
+    List<Category> result = new ArrayList<>();
+    for (Category category : categories) {
+      List<Item> matched = new ArrayList<>();
+      Map<Item, Double> scores = new LinkedHashMap<>();
+      for (Item item : category.items()) {
+        double score = item.matchScore(matcher);
+        if (score > 0.0) {
+          scores.put(item, score);
+          matched.add(item);
+        }
+      }
+      if (matched.isEmpty()) {
+        continue;
+      }
+      matched.sort(
+          (a, b) -> {
+            int byScore = Double.compare(scores.get(b), scores.get(a));
+            if (byScore != 0) {
+              return byScore;
+            }
+            return String.CASE_INSENSITIVE_ORDER.compare(a.name(), b.name());
+          });
+      result.add(new Category(category.name(), matched));
+    }
+    return result;
+  }
+}
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
new file mode 100644
index 0000000000..2510e98746
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
@@ -0,0 +1,489 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.palette;
+
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.ConstUi;
+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.TreeMemory;
+import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites.Kind;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Category;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Item;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DragSource;
+import org.eclipse.swt.dnd.DragSourceAdapter;
+import org.eclipse.swt.dnd.DragSourceEvent;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
+import org.eclipse.swt.graphics.GC;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Listener;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swt.widgets.ToolItem;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
+
+/** Spoon-style categorized tree of transforms or workflow actions (issue 
#7114). */
+public class GraphPaletteTree extends Composite {
+
+  private static final Class<?> PKG = GraphPaletteTree.class;
+
+  private static final String TREE_MEMORY_PIPELINE = "PipelinePaletteTree";
+  private static final String TREE_MEMORY_WORKFLOW = "WorkflowPaletteTree";
+
+  private final IGraphPaletteHost host;
+  private final String treeMemoryName;
+  private final String eventGuiId;
+
+  private Text filterText;
+  private Tree tree;
+  private GraphPaletteModel model;
+  private boolean populated;
+  private boolean dirty = true;
+  private boolean shiftHeld;
+  private boolean altHeld;
+  private Listener shiftKeyFilter;
+  private Image dragImage;
+
+  public GraphPaletteTree(Composite parent, IGraphPaletteHost host) {
+    super(parent, SWT.NONE);
+    this.host = host;
+    this.treeMemoryName =
+        host.getPaletteKind() == Kind.TRANSFORM ? TREE_MEMORY_PIPELINE : 
TREE_MEMORY_WORKFLOW;
+    this.eventGuiId = "GraphPaletteTree-" + host.getPaletteHostId();
+
+    FormLayout layout = new FormLayout();
+    layout.marginWidth = 0;
+    layout.marginHeight = 0;
+    setLayout(layout);
+    PropsUi.setLook(this);
+
+    createFilter();
+    ToolBar toolBar = createToolbar();
+    createTree(toolBar);
+    createContextMenu();
+    createDragSource();
+
+    host.getHopGui()
+        .getEventsHandler()
+        .addEventListener(eventGuiId, e -> asyncRefresh(), 
HopGuiEvents.FavoritesChanged.name());
+
+    addDisposeListener(e -> 
host.getHopGui().getEventsHandler().removeEventListeners(eventGuiId));
+  }
+
+  private void createFilter() {
+    filterText = new Text(this, SWT.SEARCH | SWT.ICON_SEARCH | 
SWT.ICON_CANCEL);
+    PropsUi.setLook(filterText);
+    filterText.setMessage(
+        BaseMessages.getString(
+            PKG,
+            host.getPaletteKind() == Kind.TRANSFORM
+                ? "GraphPalette.Filter.Transforms.Placeholder"
+                : "GraphPalette.Filter.Actions.Placeholder"));
+    FormData fd = new FormData();
+    fd.left = new FormAttachment(0, 0);
+    fd.top = new FormAttachment(0, 0);
+    fd.right = new FormAttachment(100, 0);
+    filterText.setLayoutData(fd);
+    filterText.addListener(SWT.Modify, e -> rebuildTree());
+    filterText.addListener(
+        SWT.DefaultSelection,
+        e -> {
+          if (e.detail == SWT.ICON_CANCEL) {
+            clearFilter();
+          }
+        });
+  }
+
+  private ToolBar createToolbar() {
+    ToolBar toolBar = new ToolBar(this, SWT.FLAT | SWT.HORIZONTAL | SWT.WRAP);
+    PropsUi.setLook(toolBar, org.apache.hop.core.Props.WIDGET_STYLE_TOOLBAR);
+    FormData fd = new FormData();
+    fd.left = new FormAttachment(0, 0);
+    fd.top = new FormAttachment(filterText, PropsUi.getMargin());
+    fd.right = new FormAttachment(100, 0);
+    toolBar.setLayoutData(fd);
+
+    GuiResource images = GuiResource.getInstance();
+    ToolItem expand = new ToolItem(toolBar, SWT.PUSH);
+    expand.setImage(images.getImage("ui/images/expand-all.svg"));
+    expand.setToolTipText(BaseMessages.getString(PKG, 
"GraphPalette.Toolbar.ExpandAll.Tooltip"));
+    expand.addListener(SWT.Selection, e -> expandAll(true));
+
+    ToolItem collapse = new ToolItem(toolBar, SWT.PUSH);
+    collapse.setImage(images.getImage("ui/images/collapse-all.svg"));
+    collapse.setToolTipText(
+        BaseMessages.getString(PKG, 
"GraphPalette.Toolbar.CollapseAll.Tooltip"));
+    collapse.addListener(SWT.Selection, e -> expandAll(false));
+
+    ToolItem clear = new ToolItem(toolBar, SWT.PUSH);
+    clear.setImage(images.getImage("ui/images/clear.svg"));
+    clear.setToolTipText(BaseMessages.getString(PKG, 
"GraphPalette.Toolbar.ClearFilter.Tooltip"));
+    clear.addListener(SWT.Selection, e -> clearFilter());
+
+    toolBar.pack();
+    return toolBar;
+  }
+
+  private void createTree(ToolBar toolBar) {
+    Composite border = new Composite(this, SWT.BORDER);
+    border.setLayout(new FormLayout());
+    FormData borderFd = new FormData();
+    borderFd.left = new FormAttachment(0, 0);
+    borderFd.top = new FormAttachment(toolBar, PropsUi.getMargin());
+    borderFd.right = new FormAttachment(100, 0);
+    borderFd.bottom = new FormAttachment(100, 0);
+    border.setLayoutData(borderFd);
+    PropsUi.setLook(border);
+
+    tree = new Tree(border, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    tree.setHeaderVisible(false);
+    PropsUi.setLook(tree);
+    FormData treeFd = new FormData();
+    treeFd.left = new FormAttachment(0, 0);
+    treeFd.top = new FormAttachment(0, 0);
+    treeFd.right = new FormAttachment(100, 0);
+    treeFd.bottom = new FormAttachment(100, 0);
+    tree.setLayoutData(treeFd);
+
+    TreeMemory.addTreeListener(tree, treeMemoryName);
+
+    tree.addListener(SWT.MouseDown, this::onMouseDown);
+    tree.addListener(SWT.DefaultSelection, this::onDefaultSelection);
+    tree.addListener(SWT.MouseHover, this::onHover);
+    tree.addListener(SWT.MouseMove, this::onHover);
+  }
+
+  private void createContextMenu() {
+    Menu menu = new Menu(tree);
+    MenuItem favoriteItem = new MenuItem(menu, SWT.PUSH);
+    favoriteItem.addListener(SWT.Selection, e -> 
toggleFavorite(selectedItem()));
+    tree.setMenu(menu);
+    tree.addListener(
+        SWT.MenuDetect,
+        event -> {
+          Item item = selectedItem();
+          if (item == null) {
+            event.doit = false;
+            return;
+          }
+          boolean favorite = 
GuiActionFavorites.isFavorite(host.getPaletteKind(), item.pluginId());
+          favoriteItem.setText(
+              BaseMessages.getString(
+                  PKG,
+                  favorite ? "GraphPalette.Menu.RemoveFavorite" : 
"GraphPalette.Menu.AddFavorite"));
+        });
+  }
+
+  private void createDragSource() {
+    DragSource dragSource = new DragSource(tree, DND.DROP_COPY);
+    dragSource.setTransfer(new Transfer[] {TextTransfer.getInstance()});
+    dragSource.addDragListener(
+        new DragSourceAdapter() {
+          @Override
+          public void dragStart(DragSourceEvent event) {
+            Item item = selectedItem();
+            if (item == null || altHeld) {
+              event.doit = false;
+              return;
+            }
+            installShiftKeyFilter();
+            setDragImage(event);
+          }
+
+          @Override
+          public void dragSetData(DragSourceEvent event) {
+            Item item = selectedItem();
+            if (item == null || 
!TextTransfer.getInstance().isSupportedType(event.dataType)) {
+              event.doit = false;
+              return;
+            }
+            event.data = ContextDialogPlacement.encode(item.actionId(), 
shiftHeld);
+          }
+
+          @Override
+          public void dragFinished(DragSourceEvent event) {
+            removeShiftKeyFilter();
+            if (dragImage != null) {
+              dragImage.dispose();
+              dragImage = null;
+            }
+          }
+        });
+  }
+
+  private void setDragImage(DragSourceEvent event) {
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      event.image = GuiResource.getInstance().getImageHop();
+      return;
+    }
+    TreeItem[] selection = tree.getSelection();
+    if (selection == null || selection.length == 0) {
+      return;
+    }
+    Rectangle bounds = selection[0].getBounds();
+    int w = Math.max(1, bounds.width);
+    int h = Math.max(1, bounds.height);
+    try {
+      dragImage = new Image(getDisplay(), w, h);
+      GC gc = new GC(tree);
+      try {
+        gc.copyArea(dragImage, bounds.x, bounds.y);
+      } finally {
+        gc.dispose();
+      }
+      event.image = dragImage;
+    } catch (Exception e) {
+      // Fall back to the default drag image.
+    }
+  }
+
+  private void installShiftKeyFilter() {
+    removeShiftKeyFilter();
+    Display display = getDisplay();
+    shiftKeyFilter =
+        event -> {
+          if (event.keyCode == SWT.SHIFT) {
+            shiftHeld = event.type == SWT.KeyDown;
+          }
+        };
+    display.addFilter(SWT.KeyDown, shiftKeyFilter);
+    display.addFilter(SWT.KeyUp, shiftKeyFilter);
+  }
+
+  private void removeShiftKeyFilter() {
+    if (shiftKeyFilter == null) {
+      return;
+    }
+    Display display = getDisplay();
+    if (display != null && !display.isDisposed()) {
+      display.removeFilter(SWT.KeyDown, shiftKeyFilter);
+      display.removeFilter(SWT.KeyUp, shiftKeyFilter);
+    }
+    shiftKeyFilter = null;
+  }
+
+  private void onMouseDown(Event event) {
+    shiftHeld = (event.stateMask & SWT.SHIFT) != 0;
+    altHeld = (event.stateMask & SWT.ALT) != 0;
+    if (!altHeld || event.button != 1) {
+      return;
+    }
+    TreeItem treeItem = tree.getItem(new 
org.eclipse.swt.graphics.Point(event.x, event.y));
+    Item item = itemOf(treeItem);
+    if (item != null) {
+      toggleFavorite(item);
+    }
+  }
+
+  private void onDefaultSelection(Event event) {
+    // Shift-double-click does not always change Tree.getSelection(); use the 
item that was
+    // actually activated (event.item) so we add that plugin, not a previously 
selected one.
+    TreeItem treeItem = event.item instanceof TreeItem ti ? ti : null;
+    Item item = itemOf(treeItem);
+    if (item == null) {
+      item = selectedItem();
+    }
+    if (item == null) {
+      return;
+    }
+    if (treeItem != null) {
+      tree.setSelection(treeItem);
+    }
+    if ((event.stateMask & SWT.ALT) != 0) {
+      toggleFavorite(item);
+      return;
+    }
+    boolean chain = (event.stateMask & SWT.SHIFT) != 0 || shiftHeld;
+    // Shift-double-click: let the graph place the new item in a row after the 
last/selected
+    // one (null location). Plain double-click still uses the last canvas 
click.
+    Point location = chain ? null : host.getPaletteDropLocation();
+    host.placePaletteAction(item.actionId(), location, chain);
+  }
+
+  private void onHover(Event event) {
+    TreeItem treeItem = tree.getItem(new 
org.eclipse.swt.graphics.Point(event.x, event.y));
+    Item item = itemOf(treeItem);
+    if (item == null) {
+      tree.setToolTipText(null);
+      return;
+    }
+    boolean favorite = GuiActionFavorites.isFavorite(host.getPaletteKind(), 
item.pluginId());
+    
tree.setToolTipText(GuiActionFavorites.tooltipWithFavoriteHint(item.description(),
 favorite));
+  }
+
+  private void toggleFavorite(Item item) {
+    if (item == null) {
+      return;
+    }
+    GuiActionFavorites.toggle(host.getPaletteKind(), item.pluginId());
+    host.persistFavoritesChange();
+  }
+
+  /** Rebuild from plugins the next time the tree is shown, or immediately 
when already visible. */
+  public void refresh() {
+    dirty = true;
+    if (!isDisposed() && GraphPalette.isVisible()) {
+      rebuildTree();
+    }
+  }
+
+  public void ensurePopulated() {
+    if (isDisposed()) {
+      return;
+    }
+    if (dirty || !populated) {
+      rebuildTree();
+    }
+  }
+
+  private void asyncRefresh() {
+    if (isDisposed()) {
+      return;
+    }
+    getDisplay()
+        .asyncExec(
+            () -> {
+              if (!isDisposed()) {
+                refresh();
+              }
+            });
+  }
+
+  private void rebuildTree() {
+    if (tree == null || tree.isDisposed()) {
+      return;
+    }
+    model = GraphPaletteModel.fromPlugins(host.getPaletteKind());
+    String filter = filterText == null || filterText.isDisposed() ? "" : 
filterText.getText();
+    List<Category> categories = model.filter(filter);
+    boolean filtering = StringUtils.isNotEmpty(filter);
+
+    tree.setRedraw(false);
+    try {
+      tree.removeAll();
+      GuiResource images = GuiResource.getInstance();
+      String favoritesName = GraphPaletteModel.favoritesCategoryName();
+      for (Category category : categories) {
+        TreeItem categoryItem = new TreeItem(tree, SWT.NONE);
+        categoryItem.setText(category.name());
+        if (favoritesName.equals(category.name())) {
+          categoryItem.setImage(images.getImage("ui/images/bookmark-add.svg"));
+        } else {
+          categoryItem.setImage(images.getImageFolder());
+        }
+        for (Item item : category.items()) {
+          TreeItem leaf = new TreeItem(categoryItem, SWT.NONE);
+          leaf.setText(item.name());
+          leaf.setImage(iconFor(item));
+          leaf.setData(item);
+        }
+      }
+      if (filtering) {
+        expandAll(true);
+      } else if (!populated) {
+        expandAll(true);
+      } else {
+        TreeMemory.setExpandedFromMemory(tree, treeMemoryName);
+      }
+    } finally {
+      tree.setRedraw(true);
+    }
+    populated = true;
+    dirty = false;
+  }
+
+  private Image iconFor(Item item) {
+    int size = ConstUi.SMALL_ICON_SIZE;
+    GuiResource images = GuiResource.getInstance();
+    if (host.getPaletteKind() == Kind.TRANSFORM) {
+      return images
+          .getSwtImageTransform(item.pluginId())
+          .getAsBitmapForSize(getDisplay(), size, size);
+    }
+    return 
images.getSwtImageAction(item.pluginId()).getAsBitmapForSize(getDisplay(), 
size, size);
+  }
+
+  private void expandAll(boolean expanded) {
+    if (tree == null || tree.isDisposed()) {
+      return;
+    }
+    tree.setRedraw(false);
+    try {
+      for (TreeItem item : tree.getItems()) {
+        expandTreeItem(item, expanded);
+      }
+    } finally {
+      tree.setRedraw(true);
+    }
+  }
+
+  private void expandTreeItem(TreeItem item, boolean expanded) {
+    item.setExpanded(expanded);
+    TreeMemory.getInstance().storeExpanded(treeMemoryName, item, expanded);
+    for (TreeItem child : item.getItems()) {
+      expandTreeItem(child, expanded);
+    }
+  }
+
+  public void clearFilter() {
+    if (filterText == null || filterText.isDisposed()) {
+      return;
+    }
+    if (StringUtils.isNotEmpty(filterText.getText())) {
+      filterText.setText("");
+    } else {
+      rebuildTree();
+    }
+    filterText.setFocus();
+  }
+
+  private Item selectedItem() {
+    if (tree == null || tree.isDisposed() || tree.getSelectionCount() != 1) {
+      return null;
+    }
+    return itemOf(tree.getSelection()[0]);
+  }
+
+  private static Item itemOf(TreeItem treeItem) {
+    if (treeItem == null) {
+      return null;
+    }
+    Object data = treeItem.getData();
+    return data instanceof Item item ? item : null;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/IGraphPaletteHost.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/IGraphPaletteHost.java
new file mode 100644
index 0000000000..4e1e86894d
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/IGraphPaletteHost.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.palette;
+
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+
+/** Pipeline or workflow graph that hosts a {@link GraphPaletteTree}. */
+public interface IGraphPaletteHost {
+
+  HopGui getHopGui();
+
+  /** Stable id for event-listener registration (typically the graph UUID). */
+  String getPaletteHostId();
+
+  GuiActionFavorites.Kind getPaletteKind();
+
+  /**
+   * Place a create-transform / create-action at {@code graphLocation}.
+   *
+   * @param graphLocation canvas graph coordinates, or {@code null} to place 
in a row after the
+   *     chain source (Shift-double-click)
+   * @param chainHop when true, also hop from the selected item, or the last 
chained item
+   */
+  boolean placePaletteAction(String actionId, Point graphLocation, boolean 
chainHop);
+
+  /** Last canvas click in graph coordinates, or the visible canvas center. */
+  Point getPaletteDropLocation();
+
+  /** Show or hide the palette sash from the persisted {@link 
GraphPalette#isVisible()} flag. */
+  void applyPaletteVisibility();
+
+  /** Persist a favorite toggle and notify other open palettes. */
+  void persistFavoritesChange();
+}
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/palette/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/palette/messages/messages_en_US.properties
new file mode 100644
index 0000000000..4e61acff2d
--- /dev/null
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/palette/messages/messages_en_US.properties
@@ -0,0 +1,26 @@
+#
+# 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.
+#
+
+GraphPalette.Toolbar.Show.Tooltip=Show the transform/action palette tree
+GraphPalette.Toolbar.Hide.Tooltip=Hide the transform/action palette tree
+GraphPalette.Filter.Transforms.Placeholder=Filter transforms
+GraphPalette.Filter.Actions.Placeholder=Filter actions
+GraphPalette.Toolbar.ExpandAll.Tooltip=Expand all categories
+GraphPalette.Toolbar.CollapseAll.Tooltip=Collapse all categories
+GraphPalette.Toolbar.ClearFilter.Tooltip=Clear filter
+GraphPalette.Menu.AddFavorite=Add to favorites
+GraphPalette.Menu.RemoveFavorite=Remove from favorites
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
index 14e34093e3..536003292f 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
@@ -110,6 +110,56 @@ class HopGuiKeyHandlerTest {
     }
   }
 
+  /**
+   * Pipeline graphs bind Space to "show output fields". That must not eat 
Space in a filter Text
+   * (the palette tree search box).
+   */
+  public static class SpaceGraph {
+    public int spaces;
+    public int letters;
+
+    @GuiKeyboardShortcut(key = ' ')
+    @GuiOsxKeyboardShortcut(key = ' ')
+    public void showOutputFields() {
+      spaces++;
+    }
+
+    @GuiKeyboardShortcut(key = 'z')
+    @GuiOsxKeyboardShortcut(key = 'z')
+    public void openReferencedObject() {
+      letters++;
+    }
+  }
+
+  @Test
+  void spaceAndLettersAreLeftToTextWidgets() {
+    SpaceGraph graph = new SpaceGraph();
+    registerShortcutsLikeHopGuiEnvironment(SpaceGraph.class);
+
+    HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+    keyHandler.addParentObjectToHandle(graph);
+    try {
+      KeyEvent spaceInText = keyEvent(mock(Text.class), SWT.SPACE, SWT.NONE);
+      spaceInText.character = ' ';
+      keyHandler.keyPressed(spaceInText);
+      assertEquals(0, graph.spaces, "Space must type into text widgets, not 
run graph shortcuts");
+      assertTrue(spaceInText.doit, "Space must not be consumed when a text 
widget has focus");
+
+      KeyEvent letterInText = keyEvent(mock(Text.class), 'z', SWT.NONE);
+      letterInText.character = 'z';
+      keyHandler.keyPressed(letterInText);
+      assertEquals(
+          0, graph.letters, "Letters must type into text widgets, not run 
graph shortcuts");
+
+      KeyEvent spaceOnCanvas = canvasKey(SWT.SPACE, SWT.NONE);
+      spaceOnCanvas.character = ' ';
+      keyHandler.keyPressed(spaceOnCanvas);
+      assertEquals(1, graph.spaces, "Space on the canvas still runs the graph 
shortcut");
+    } finally {
+      keyHandler.removeParentObjectToHandle(graph);
+    }
+  }
+
   @Test
   void arrowKeysAreLeftToTablesAndTrees() {
     NavigationGraph graph = new NavigationGraph();
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacementTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacementTest.java
new file mode 100644
index 0000000000..7911496b9e
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacementTest.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.context;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.junit.jupiter.api.Test;
+
+class ContextDialogPlacementTest {
+
+  @Test
+  void encodeDecodeCreateAction() {
+    GuiAction action =
+        new GuiAction(
+            "pipeline-graph-create-transform-Dummy",
+            GuiActionType.Create,
+            "Dummy",
+            "desc",
+            null,
+            (a, b, c) -> {});
+    String payload = ContextDialogPlacement.encode(action);
+    assertTrue(ContextDialogPlacement.isPlacementPayload(payload));
+    assertFalse(ContextDialogPlacement.isChainPayload(payload));
+    assertEquals(action.getId(), 
ContextDialogPlacement.decodeActionId(payload));
+  }
+
+  @Test
+  void encodeDecodeChainPayload() {
+    String payload = 
ContextDialogPlacement.encode("pipeline-graph-create-transform-Dummy", true);
+    assertTrue(ContextDialogPlacement.isPlacementPayload(payload));
+    assertTrue(ContextDialogPlacement.isChainPayload(payload));
+    assertEquals(
+        "pipeline-graph-create-transform-Dummy", 
ContextDialogPlacement.decodeActionId(payload));
+  }
+
+  @Test
+  void ignoresUnrelatedText() {
+    assertFalse(ContextDialogPlacement.isPlacementPayload("hello"));
+    assertNull(ContextDialogPlacement.decodeActionId("hello"));
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModelTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModelTest.java
new file mode 100644
index 0000000000..dd3a302b71
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModelTest.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.palette;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Category;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Item;
+import org.junit.jupiter.api.Test;
+
+class GraphPaletteModelTest {
+
+  private static Item item(String id, String name, String category, String... 
keywords) {
+    return new Item(
+        id,
+        GuiActionFavorites.createId(GuiActionFavorites.Kind.TRANSFORM, id),
+        name,
+        category,
+        name + " description",
+        "ui/images/" + id + ".svg",
+        List.of(keywords));
+  }
+
+  @Test
+  void favoritesCategoryIsFirstAndKeepsStoredOrder() {
+    Item csv = item("CsvInput", "CSV file input", "Input");
+    Item table = item("TableInput", "Table input", "Input");
+    Item text = item("TextFileInput", "Text file input", "Input");
+    Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+
+    GraphPaletteModel model =
+        GraphPaletteModel.fromItems(
+            List.of(csv, table, text, dummy), List.of("TextFileInput", 
"CsvInput"), "Favorites");
+
+    List<Category> categories = model.getCategories();
+    assertEquals("Favorites", categories.get(0).name());
+    assertEquals(List.of("TextFileInput", "CsvInput"), 
pluginIds(categories.get(0)));
+    assertEquals("Flow", categories.get(1).name());
+    assertEquals("Input", categories.get(2).name());
+    assertEquals(
+        List.of("CSV file input", "Table input", "Text file input"), 
names(categories.get(2)));
+  }
+
+  @Test
+  void emptyFavoritesOmitsTheCategory() {
+    Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+    GraphPaletteModel model = GraphPaletteModel.fromItems(List.of(dummy), 
List.of(), "Favorites");
+    assertEquals(1, model.getCategories().size());
+    assertEquals("Flow", model.getCategories().get(0).name());
+  }
+
+  @Test
+  void unknownFavoriteIdsAreIgnored() {
+    Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+    GraphPaletteModel model =
+        GraphPaletteModel.fromItems(List.of(dummy), List.of("DoesNotExist"), 
"Favorites");
+    assertEquals(1, model.getCategories().size());
+    assertEquals("Flow", model.getCategories().get(0).name());
+  }
+
+  @Test
+  void itemsAndCategoriesAreAlphabetical() {
+    Item zebra = item("Zebra", "Zebra", "Zeta");
+    Item alpha = item("Alpha", "Alpha", "Zeta");
+    Item mid = item("Mid", "Mid", "Alpha");
+    GraphPaletteModel model =
+        GraphPaletteModel.fromItems(List.of(zebra, alpha, mid), List.of(), 
"Favorites");
+    assertEquals(
+        List.of("Alpha", "Zeta"), 
model.getCategories().stream().map(Category::name).toList());
+    assertEquals(List.of("Alpha", "Zebra"), 
names(model.getCategories().get(1)));
+  }
+
+  @Test
+  void filterDropsEmptyCategoriesAndMatchesNameKeywordAndCategory() {
+    Item csv = item("CsvInput", "CSV file input", "Input", "file");
+    Item table = item("TableInput", "Table input", "Input");
+    Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+    GraphPaletteModel model =
+        GraphPaletteModel.fromItems(List.of(csv, table, dummy), List.of(), 
"Favorites");
+
+    List<Category> csvHit = model.filter("csv");
+    assertEquals(1, csvHit.size());
+    assertEquals("Input", csvHit.get(0).name());
+    assertEquals(List.of("CSV file input"), names(csvHit.get(0)));
+
+    List<Category> fileHit = model.filter("file");
+    assertEquals(1, fileHit.size());
+    assertEquals(List.of("CSV file input"), names(fileHit.get(0)));
+
+    List<Category> flowHit = model.filter("flow");
+    assertEquals(1, flowHit.size());
+    assertEquals("Flow", flowHit.get(0).name());
+    assertEquals(List.of("Dummy (do nothing)"), names(flowHit.get(0)));
+
+    assertTrue(model.filter("zzz-no-such-plugin").isEmpty());
+  }
+
+  @Test
+  void blankFilterReturnsEveryCategory() {
+    Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+    GraphPaletteModel model = GraphPaletteModel.fromItems(List.of(dummy), 
List.of(), "Favorites");
+    assertEquals(model.getCategories(), model.filter(""));
+    assertEquals(model.getCategories(), model.filter(null));
+  }
+
+  @Test
+  void actionIdUsesCreatePrefix() {
+    Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+    assertEquals("pipeline-graph-create-transform-Dummy", dummy.actionId());
+  }
+
+  private static List<String> pluginIds(Category category) {
+    return category.items().stream().map(Item::pluginId).toList();
+  }
+
+  private static List<String> names(Category category) {
+    return category.items().stream().map(Item::name).toList();
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTest.java
new file mode 100644
index 0000000000..27a6fc288c
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.palette;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.config.HopConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GraphPaletteTest {
+
+  private String saved;
+
+  @BeforeEach
+  void setUp() {
+    saved = HopConfig.getGuiProperty(GraphPalette.CONFIG_KEY);
+    HopConfig.readGuiProperties().remove(GraphPalette.CONFIG_KEY);
+  }
+
+  @AfterEach
+  void tearDown() {
+    if (saved == null) {
+      HopConfig.readGuiProperties().remove(GraphPalette.CONFIG_KEY);
+    } else {
+      HopConfig.setGuiProperty(GraphPalette.CONFIG_KEY, saved);
+    }
+  }
+
+  @Test
+  void hiddenByDefault() {
+    assertFalse(GraphPalette.isVisible());
+  }
+
+  @Test
+  void setVisibleUpdatesTheFlagWithoutRequiringYOnMissingKey() {
+    HopConfig.setGuiProperty(GraphPalette.CONFIG_KEY, "Y");
+    assertTrue(GraphPalette.isVisible());
+    HopConfig.setGuiProperty(GraphPalette.CONFIG_KEY, "N");
+    assertFalse(GraphPalette.isVisible());
+  }
+}

Reply via email to