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 f2d943eb43 Improvements to the metrics tab in pipelines, fixes #6451 
(#6496)
f2d943eb43 is described below

commit f2d943eb4339ba415962694fb05f4d5b0ecfff7e
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed Feb 4 10:53:55 2026 +0100

    Improvements to the metrics tab in pipelines, fixes #6451 (#6496)
---
 .../hop/ui/core/widget/svg/SvgLabelFacadeImpl.java |  22 +
 .../main/java/org/apache/hop/ui/hopgui/HopWeb.java |  13 +
 .../apache/hop/ui/core/gui/GuiToolbarWidgets.java  |  45 ++
 .../hop/ui/core/widget/svg/SvgLabelFacade.java     |  17 +
 .../delegates/HopGuiPipelineGridDelegate.java      | 690 +++++++++++++++------
 .../delegates/HopGuiPipelineLogDelegate.java       |  16 +-
 .../delegates/HopGuiWorkflowLogDelegate.java       |   6 +-
 .../hopgui/selection/HopGuiSelectionTracker.java   |  30 +-
 .../org/apache/hop/ui/util/SwtSvgImageUtil.java    |  13 +-
 .../ui/hopgui/messages/messages_en_US.properties   |   1 +
 10 files changed, 652 insertions(+), 201 deletions(-)

diff --git 
a/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java
index 6c314c5555..2c8e03b827 100644
--- 
a/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java
+++ 
b/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java
@@ -96,6 +96,28 @@ public class SvgLabelFacadeImpl extends SvgLabelFacade {
         "; }");
   }
 
+  @Override
+  public void updateImageSourceInternal(String id, Label label, String 
imagePath) {
+    try {
+      String src = RWT.getResourceManager().getLocation(imagePath);
+      if (src == null) {
+        return;
+      }
+      // Update the img src via JavaScript so the icon updates without 
replacing label markup
+      // (setText with new markup may not re-render in RWT)
+      String escaped = src.replace("\\", "\\\\").replace("'", "\\'");
+      exec("var el = document.getElementById('", id, "'); if (el) { el.src='", 
escaped, "'; }");
+    } catch (Exception e) {
+      System.err.println(
+          "Error updating image source for tool-item "
+              + id
+              + " for filename: "
+              + imagePath
+              + " - "
+              + Const.getSimpleStackTrace(e));
+    }
+  }
+
   private static void exec(String... strings) {
     StringBuilder builder = new StringBuilder();
     builder.append("try {");
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index edd3d02928..eab16461a8 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -76,6 +76,19 @@ public class HopWeb implements ApplicationConfiguration {
         }
       }
 
+      // Register alternate images for toolbar toggles (e.g. show/hide, 
show-all/show-selected)
+      // so setToolbarItemImage() can switch icons in RWT without "Resource 
does not exist"
+      ClassLoader uiClassLoader = HopWeb.class.getClassLoader();
+      for (String path :
+          new String[] {
+            "ui/images/show.svg",
+            "ui/images/hide.svg",
+            "ui/images/show-all.svg",
+            "ui/images/show-selected.svg"
+          }) {
+        addResource(application, path, uiClassLoader);
+      }
+
       // Find metadata, perspective plugins
       //
       List<IPlugin> plugins = 
PluginRegistry.getInstance().getPlugins(MetadataPluginType.class);
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
index f63ea58ce0..12336abb61 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
@@ -364,6 +364,10 @@ public class GuiToolbarWidgets extends BaseGuiWidgets {
     // This prevents ID collisions when multiple tabs have the same toolbar 
items
     String uniqueId = instanceId + "-" + toolbarItem.getId();
     SvgLabelFacade.setData(uniqueId, imageLabel, imageFilename, size);
+    // Store so setToolbarItemImage() can update the icon when toggling state 
(e.g. show/hide
+    // selected)
+    composite.setData("iconSize", size);
+    composite.setData("uniqueId", uniqueId);
 
     GridData imageData = new GridData(SWT.LEFT, SWT.CENTER, false, false);
     imageData.widthHint = size;
@@ -452,6 +456,9 @@ public class GuiToolbarWidgets extends BaseGuiWidgets {
           String uniqueId = instanceId + "-" + id;
           SvgLabelFacade.enable(toolItem, uniqueId, imageLabel, enabled);
         }
+        // So that disabled buttons do not receive clicks in RWT 
(ToolItem.setEnabled does not
+        // always prevent the control from receiving events)
+        composite.setEnabled(enabled);
       }
       // Also update the ToolItem state for consistency
       if (enabled != toolItem.isEnabled()) {
@@ -508,6 +515,8 @@ public class GuiToolbarWidgets extends BaseGuiWidgets {
             String uniqueId = instanceId + "-" + id;
             SvgLabelFacade.enable(null, uniqueId, imageLabel, enable);
           }
+          // So that disabled buttons do not receive clicks in RWT
+          composite.setEnabled(enable);
         }
         // Update ToolItem state so future checks work correctly
         item.setEnabled(enable);
@@ -522,6 +531,42 @@ public class GuiToolbarWidgets extends BaseGuiWidgets {
     return toolItemMap.get(id);
   }
 
+  /**
+   * Set the image of a toolbar item by path. Use this when toggling between 
two icons (e.g. show
+   * only selected / show all). In desktop SWT this sets the ToolItem's image; 
in web RWT this
+   * updates the SVG in the label so the icon change is visible.
+   *
+   * @param id the toolbar item id (from @GuiToolbarElement)
+   * @param imagePath the image path (e.g. "ui/images/show-selected.svg")
+   */
+  public void setToolbarItemImage(String id, String imagePath) {
+    if (StringUtils.isEmpty(imagePath)) {
+      return;
+    }
+    ToolItem toolItem = toolItemMap.get(id);
+    if (toolItem == null || toolItem.isDisposed()) {
+      return;
+    }
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      Control control = widgetsMap.get(id);
+      if (control instanceof Composite composite && !composite.isDisposed()) {
+        Control[] children = composite.getChildren();
+        if (children.length > 0 && children[0] instanceof Label imageLabel) {
+          String uniqueId = (String) composite.getData("uniqueId");
+          if (uniqueId != null) {
+            // Update img src via JavaScript so the icon updates in RWT 
(setText may not re-render)
+            SvgLabelFacade.updateImageSource(uniqueId, imageLabel, imagePath);
+          }
+        }
+      }
+    } else {
+      Image image = GuiResource.getInstance().getImage(imagePath);
+      if (image != null) {
+        toolItem.setImage(image);
+      }
+    }
+  }
+
   /**
    * Set text on a toolbar item. Handles both SWT (desktop) and RWT (web) 
environments. In SWT, sets
    * text directly on the ToolItem. In RWT, updates the separate text Label 
next to the image.
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacade.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacade.java
index 80b6dad25e..d1f8af4475 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacade.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacade.java
@@ -55,4 +55,21 @@ public abstract class SvgLabelFacade {
   }
 
   public abstract void shadeSvgInternal(Label label, String id, boolean 
shaded);
+
+  /**
+   * Update only the image source of an existing img element (e.g. when 
toggling toolbar icon). In
+   * RWT this uses JavaScript to set the img src so the icon updates without 
replacing the whole
+   * label markup. No-op on desktop.
+   *
+   * @param id the DOM element id of the img (same uniqueId used in setData)
+   * @param label the label widget (unused in RWT but required for API)
+   * @param imagePath the new image path (e.g. "ui/images/show-selected.svg")
+   */
+  public static void updateImageSource(String id, Label label, String 
imagePath) {
+    synchronized (object) {
+      IMPL.updateImageSourceInternal(id, label, imagePath);
+    }
+  }
+
+  public abstract void updateImageSourceInternal(String id, Label label, 
String imagePath);
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
index 263fc83095..0b17300468 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
@@ -22,7 +22,9 @@ import java.text.NumberFormat;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.Timer;
 import java.util.TimerTask;
 import java.util.concurrent.locks.ReentrantLock;
@@ -37,10 +39,12 @@ import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.util.ExecutorUtil;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.engine.EngineMetrics;
 import org.apache.hop.pipeline.engine.IEngineComponent;
 import org.apache.hop.pipeline.engine.IEngineMetric;
 import org.apache.hop.pipeline.transform.ITransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.pipeline.transform.TransformStatus;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.GuiResource;
@@ -50,23 +54,37 @@ import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.selection.HopGuiSelectionTracker;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabItem;
 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.Table;
+import org.eclipse.swt.widgets.TableColumn;
 import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
 import org.eclipse.swt.widgets.ToolBar;
 import org.eclipse.swt.widgets.ToolItem;
 
+/**
+ * Delegate for the pipeline execution metrics grid tab. Manages the table of 
transform metrics
+ * (rows read/written, duration, etc.), toolbar actions (open transform, 
show/hide filters, copy),
+ * sorting, and double-click / selection behaviour to open the transform 
configuration.
+ */
 @GuiPlugin(description = "Pipeline Graph Grid Delegate")
 public class HopGuiPipelineGridDelegate {
   private static final Class<?> PKG = HopGui.class;
 
   public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = 
"HopGuiWorkflowGridDelegate-ToolBar";
+
+  /** Open-transform button; id 09900 so it appears first in the toolbar. */
+  public static final String TOOLBAR_ICON_OPEN_TRANSFORM = 
"ToolbarIcon-09900-OpenTransform";
+
   public static final String TOOLBAR_ICON_SHOW_HIDE_INACTIVE = 
"ToolbarIcon-10000-ShowHideInactive";
   public static final String TOOLBAR_ICON_SHOW_HIDE_SELECTED = 
"ToolbarIcon-10010-ShowHideSelected";
+  public static final String TOOLBAR_ICON_COPY = "ToolbarIcon-10020-Copy";
 
   public static final long UPDATE_TIME_VIEW = 1000L;
 
@@ -90,9 +108,28 @@ public class HopGuiPipelineGridDelegate {
 
   private Timer refreshMetricsTimer;
 
+  /** Last sort column/direction from user (column header click); we re-apply 
on each refresh. */
+  private int gridSortColumn = 0;
+
+  private boolean gridSortDescending = false;
+
+  /**
+   * Cached from last run so "show selected/inactive" filter still works after 
the refresh timer
+   * stops.
+   */
+  private EngineMetrics lastEngineMetrics;
+
+  /**
+   * Search text for filtering the metrics table by transform name. Empty or 
&lt; 2 characters means
+   * no filter (show all). Matches case-insensitive substring, like the 
settings panel search.
+   */
+  private String transformNameSearchText = "";
+
+  private Text searchText;
+
   /**
-   * @param hopGui
-   * @param pipelineGraph
+   * @param hopGui Hop GUI instance
+   * @param pipelineGraph the pipeline graph that owns this delegate
    */
   public HopGuiPipelineGridDelegate(HopGui hopGui, HopGuiPipelineGraph 
pipelineGraph) {
     this.hopGui = hopGui;
@@ -121,13 +158,20 @@ public class HopGuiPipelineGridDelegate {
       pipelineGraph.addExtraView();
     } else {
       if (pipelineGridTab != null && !pipelineGridTab.isDisposed()) {
-        // just set this one active and get out...
-        // and activate the refresh timer
+        // Reusing existing grid for a new run: reset sort to default and 
clear metrics cache
+        gridSortColumn = 0;
+        gridSortDescending = false;
+        lastEngineMetrics = null;
         startRefreshMetricsTimer();
         return;
       }
     }
 
+    gridSortColumn = 0;
+    gridSortDescending = false;
+    lastEngineMetrics = null;
+    transformNameSearchText = "";
+
     pipelineGridTab = new CTabItem(pipelineGraph.extraViewTabFolder, SWT.NONE);
     pipelineGridTab.setFont(GuiResource.getInstance().getFontDefault());
     pipelineGridTab.setImage(GuiResource.getInstance().getImageShowGrid());
@@ -233,19 +277,21 @@ public class HopGuiPipelineGridDelegate {
             SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
             columns,
             1,
-            true, // readonly!
-            null, // Listener
+            true, // readonly
+            null,
             hopGui.getProps(),
             true,
             null,
             false,
-            false);
+            false); // no TableView toolbar; copy/filter are on our toolbar
     FormData fdView = new FormData();
     fdView.left = new FormAttachment(0, 0);
     fdView.right = new FormAttachment(100, 0);
     fdView.top = new FormAttachment(toolbar, 0);
     fdView.bottom = new FormAttachment(100, 0);
     pipelineGridView.setLayoutData(fdView);
+    pipelineGridView.setSortable(true);
+    attachMetricsTableListeners(pipelineGridView);
 
     ColumnInfo numberColumn = pipelineGridView.getNumberColumn();
     IValueMeta numberColumnValueMeta =
@@ -254,6 +300,18 @@ public class HopGuiPipelineGridDelegate {
 
     startRefreshMetricsTimer();
     pipelineGridTab.addDisposeListener(disposeEvent -> 
stopRefreshMetricsTimer());
+    HopGuiSelectionTracker.getInstance()
+        .addSelectionListener(
+            HopGuiSelectionTracker.SelectionType.PIPELINE_GRAPH,
+            () -> {
+              if (!showSelectedTransforms) {
+                return;
+              }
+              if (pipelineGridView == null || pipelineGridView.isDisposed()) {
+                return;
+              }
+              hopGui.getDisplay().asyncExec(this::refreshView);
+            });
 
     pipelineGridTab.setControl(pipelineGridComposite);
   }
@@ -318,9 +376,39 @@ public class HopGuiPipelineGridDelegate {
     toolbarWidget = new GuiToolbarWidgets();
     toolbarWidget.registerGuiPluginObject(this);
     toolbarWidget.createToolbarWidgets(toolbar, GUI_PLUGIN_TOOLBAR_PARENT_ID);
+
+    // Search bar: filter table by transform name (smart search, min 2 chars, 
case-insensitive)
+    ToolItem searchSeparator = new ToolItem(toolbar, SWT.SEPARATOR);
+    searchText = new Text(toolbar, SWT.SEARCH | SWT.ICON_SEARCH | 
SWT.ICON_CANCEL | SWT.BORDER);
+    searchText.setMessage(
+        BaseMessages.getString(PKG, 
"PipelineLog.Search.TransformName.Placeholder"));
+    PropsUi.setLook(searchText, Props.WIDGET_STYLE_TOOLBAR);
+    searchText.addListener(
+        SWT.Modify,
+        e -> {
+          if (searchText == null || searchText.isDisposed()) {
+            return;
+          }
+          String raw = searchText.getText();
+          transformNameSearchText = raw != null ? raw.trim() : "";
+          refreshView();
+        });
+    searchSeparator.setControl(searchText);
+    searchSeparator.setWidth(260);
+
     toolbar.pack();
   }
 
+  /** Opens the transform configuration for the selected metrics row (same as 
double-click). */
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ICON_OPEN_TRANSFORM,
+      toolTip = 
"i18n::HopGuiPipelineGridDelegate.Toolbar.OpenTransform.Tooltip",
+      image = "ui/images/edit.svg")
+  public void openSelectedTransform() {
+    openTransformForSelectedRow();
+  }
+
   @GuiToolbarElement(
       root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ICON_SHOW_HIDE_INACTIVE,
@@ -329,14 +417,8 @@ public class HopGuiPipelineGridDelegate {
   public void showHideInactive() {
     hideInactiveTransforms = !hideInactiveTransforms;
 
-    ToolItem toolItem = 
toolbarWidget.findToolItem(TOOLBAR_ICON_SHOW_HIDE_INACTIVE);
-    if (toolItem != null) {
-      if (hideInactiveTransforms) {
-        toolItem.setImage(GuiResource.getInstance().getImageHide());
-      } else {
-        toolItem.setImage(GuiResource.getInstance().getImageShow());
-      }
-    }
+    String imagePath = hideInactiveTransforms ? "ui/images/hide.svg" : 
"ui/images/show.svg";
+    toolbarWidget.setToolbarItemImage(TOOLBAR_ICON_SHOW_HIDE_INACTIVE, 
imagePath);
     refreshView();
   }
 
@@ -348,208 +430,438 @@ public class HopGuiPipelineGridDelegate {
   public void showHideSelected() {
     showSelectedTransforms = !showSelectedTransforms;
 
-    ToolItem toolItem = 
toolbarWidget.findToolItem(TOOLBAR_ICON_SHOW_HIDE_SELECTED);
-    if (toolItem != null) {
-      if (showSelectedTransforms) {
-        toolItem.setImage(GuiResource.getInstance().getImageShowSelected());
-      } else {
-        toolItem.setImage(GuiResource.getInstance().getImageShowAll());
-      }
-    }
+    String imagePath =
+        showSelectedTransforms ? "ui/images/show-selected.svg" : 
"ui/images/show-all.svg";
+    toolbarWidget.setToolbarItemImage(TOOLBAR_ICON_SHOW_HIDE_SELECTED, 
imagePath);
     refreshView();
   }
 
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ICON_COPY,
+      toolTip = "i18n::TableView.ToolBarWidget.CopySelected.ToolTip",
+      image = "ui/images/copy.svg",
+      separator = true)
+  public void copyMetricsToClipboard() {
+    if (pipelineGridView == null || pipelineGridView.isDisposed()) {
+      return;
+    }
+    Table table = pipelineGridView.table;
+    boolean hadSelection = table.getSelectionCount() > 0;
+    if (!hadSelection) {
+      table.selectAll();
+    }
+    pipelineGridView.clipSelected();
+    if (!hadSelection) {
+      table.deselectAll();
+    }
+  }
+
   private void refreshView() {
     refreshViewLock.lock();
     try {
-      if (pipelineGraph.pipeline == null
-          || pipelineGridView == null
-          || pipelineGridView.isDisposed()) {
+      if (pipelineGridView == null || pipelineGridView.isDisposed()) {
         return;
       }
 
-      // Get the metrics from the engine
-      //
-      EngineMetrics engineMetrics = pipelineGraph.pipeline.getEngineMetrics();
-      List<IEngineComponent> shownComponents = new ArrayList<>();
-      for (IEngineComponent component : engineMetrics.getComponents()) {
-        boolean select = true;
-        // If we hide inactive components we only want to see stuff running
-        //
-        select = select && (!hideInactiveTransforms || component.isRunning());
-
-        // If we opted to only see selected components...
-        //
-        select = select && (!showSelectedTransforms || component.isSelected());
-
-        if (select) {
-          shownComponents.add(component);
-        }
+      EngineMetrics engineMetrics = null;
+      if (pipelineGraph.pipeline != null) {
+        engineMetrics = pipelineGraph.pipeline.getEngineMetrics();
+        lastEngineMetrics = engineMetrics;
+      } else if (lastEngineMetrics != null) {
+        engineMetrics = lastEngineMetrics;
       }
+      if (engineMetrics == null) {
+        return;
+      }
+      Set<String> selectedTransformNames = 
getSelectedTransformNamesFromCanvas();
+      List<IEngineComponent> shownComponents =
+          getShownComponents(engineMetrics, selectedTransformNames);
+      List<IEngineMetric> usedMetrics = getUsedMetrics(engineMetrics);
+      List<ColumnInfo> columns = buildColumnList(usedMetrics);
+      List<List<String>> componentStringsList =
+          buildComponentStringsList(engineMetrics, shownComponents, 
usedMetrics);
 
-      // Build a list of columns to show...
-      //
-      List<ColumnInfo> columns = new ArrayList<>();
+      recreateTableIfColumnsChanged(columns, shownComponents.size());
 
-      // First the name of the component (transform):
-      // Then the copy number
-      //
-      columns.add(
-          new ColumnInfo(
-              BaseMessages.getString(PKG, "PipelineLog.Column.TransformName"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              false,
-              true));
-      ColumnInfo copyColumn =
-          new ColumnInfo(
-              BaseMessages.getString(PKG, "PipelineLog.Column.Copynr"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              true,
-              true);
-      copyColumn.setAlignment(SWT.RIGHT);
-      columns.add(copyColumn);
+      sortComponentStringsByColumn(componentStringsList, gridSortColumn, 
gridSortDescending);
 
-      List<IEngineMetric> usedMetrics = new 
ArrayList<>(engineMetrics.getMetricsList());
-      Collections.sort(
-          usedMetrics, (o1, o2) -> 
o1.getDisplayPriority().compareTo(o2.getDisplayPriority()));
+      fillTableRows(componentStringsList);
 
-      for (IEngineMetric metric : usedMetrics) {
-        ColumnInfo column =
-            new ColumnInfo(
-                metric.getHeader(), ColumnInfo.COLUMN_TYPE_TEXT, 
metric.isNumeric(), true);
-        column.setToolTip(metric.getTooltip());
-        IValueMeta stringMeta = new ValueMetaString(metric.getCode());
-        ValueMetaInteger valueMeta = new ValueMetaInteger(metric.getCode(), 
15, 0);
-        valueMeta.setConversionMask(METRICS_FORMAT);
-        stringMeta.setConversionMetadata(valueMeta);
-        column.setValueMeta(stringMeta);
-        column.setAlignment(SWT.RIGHT);
-        columns.add(column);
-      }
+      setSortIndicator();
 
-      IValueMeta stringMeta = new ValueMetaString("string");
+      pipelineGridView.optWidth(true);
+      previousRefreshColumns = columns;
+      updateEditButtonState();
+    } finally {
+      refreshViewLock.unlock();
+    }
+  }
 
-      // Duration?
-      //
-      ColumnInfo durationColumn =
-          new ColumnInfo(
-              BaseMessages.getString(PKG, "PipelineLog.Column.Duration"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              false,
-              true);
-      durationColumn.setValueMeta(stringMeta);
-      durationColumn.setAlignment(SWT.RIGHT);
-      columns.add(durationColumn);
-
-      // Also add the status and speed
-      //
-      ValueMetaInteger speedMeta = new ValueMetaInteger("speed", 15, 0);
-      speedMeta.setConversionMask(" ###,###,###,##0");
-      stringMeta.setConversionMetadata(speedMeta);
-      ColumnInfo speedColumn =
-          new ColumnInfo(
-              BaseMessages.getString(PKG, "PipelineLog.Column.Speed"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              false,
-              true);
-      speedColumn.setValueMeta(stringMeta);
-      speedColumn.setAlignment(SWT.RIGHT);
-      columns.add(speedColumn);
+  /**
+   * Current canvas selection (transform names). Used so "only show selected" 
reflects the latest
+   * selection even when the refresh timer has stopped and we're using cached 
engine metrics.
+   */
+  private Set<String> getSelectedTransformNamesFromCanvas() {
+    if (pipelineGraph.getPipelineMeta() == null) {
+      return null;
+    }
+    List<TransformMeta> selected = 
pipelineGraph.getPipelineMeta().getSelectedTransforms();
+    if (selected == null || selected.isEmpty()) {
+      return null;
+    }
+    Set<String> names = new HashSet<>();
+    for (TransformMeta t : selected) {
+      if (t != null && t.getName() != null) {
+        names.add(t.getName());
+      }
+    }
+    return names.isEmpty() ? null : names;
+  }
 
-      columns.add(
-          new ColumnInfo(
-              BaseMessages.getString(PKG, "PipelineLog.Column.Status"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              false,
-              true));
-
-      // The data in the grid...
-      //
-      List<List<String>> componentStringsList = new ArrayList<>();
-      int row = 1;
-      for (IEngineComponent component : shownComponents) {
-        List<String> componentStrings = new ArrayList<>();
-
-        componentStrings.add(Integer.toString(row++));
-        componentStrings.add(Const.NVL(component.getName(), ""));
-        componentStrings.add(Integer.toString(component.getCopyNr()));
-
-        for (IEngineMetric metric : usedMetrics) {
-          Long value = engineMetrics.getComponentMetric(component, metric);
-          componentStrings.add(value == null ? "" : formatMetric(value));
+  private List<IEngineComponent> getShownComponents(
+      EngineMetrics engineMetrics, Set<String> selectedTransformNames) {
+    List<IEngineComponent> shownComponents = new ArrayList<>();
+    for (IEngineComponent component : engineMetrics.getComponents()) {
+      boolean select = true;
+      select = select && (!hideInactiveTransforms || component.isRunning());
+      if (showSelectedTransforms
+          && selectedTransformNames != null
+          && !selectedTransformNames.isEmpty()) {
+        select = select && 
selectedTransformNames.contains(component.getName());
+      }
+      // When "show selected" is on but no transforms are selected on canvas: 
show all
+      if (select) {
+        shownComponents.add(component);
+      }
+    }
+    // Smart search filter by transform name (like settings panel: min 2 
chars, case-insensitive)
+    if (transformNameSearchText != null && transformNameSearchText.length() >= 
2) {
+      String lowerSearch = transformNameSearchText.toLowerCase();
+      List<IEngineComponent> filtered = new ArrayList<>();
+      for (IEngineComponent c : shownComponents) {
+        String name = c.getName();
+        if (name != null && name.toLowerCase().contains(lowerSearch)) {
+          filtered.add(c);
         }
-        String duration = calculateDuration(component);
-        componentStrings.add(duration);
-        String speed = engineMetrics.getComponentSpeedMap().get(component);
-        componentStrings.add(Const.NVL(speed, ""));
-        String status = engineMetrics.getComponentStatusMap().get(component);
-        componentStrings.add(Const.NVL(status, ""));
-
-        componentStringsList.add(componentStrings);
       }
+      shownComponents = filtered;
+    }
+    return shownComponents;
+  }
 
-      // So now we have the columns and the content of the grid...
-      //
-      // If the number of columns has changed since the last refresh we 
rebuild the table.
-      //
-      if (haveColumnsChanged(columns)) {
-        // Remove the old stuff on the composite...
-        //
-        pipelineGridView.dispose();
-        pipelineGridView =
-            new TableView(
-                pipelineGraph.getVariables(),
-                pipelineGridComposite,
-                SWT.NONE,
-                columns.toArray(new ColumnInfo[0]),
-                shownComponents.size(),
-                true,
-                null,
-                PropsUi.getInstance(),
-                true,
-                null,
-                false,
-                false);
-        pipelineGridView.setSortable(false);
-        FormData fdView = new FormData();
-        fdView.left = new FormAttachment(0, 0);
-        fdView.right = new FormAttachment(100, 0);
-        fdView.top = new FormAttachment(toolbar, 0);
-        fdView.bottom = new FormAttachment(100, 0);
-        pipelineGridView.setLayoutData(fdView);
-        pipelineGridComposite.layout(true, true);
-      }
+  private List<IEngineMetric> getUsedMetrics(EngineMetrics engineMetrics) {
+    List<IEngineMetric> usedMetrics = new 
ArrayList<>(engineMetrics.getMetricsList());
+    Collections.sort(
+        usedMetrics, (o1, o2) -> 
o1.getDisplayPriority().compareTo(o2.getDisplayPriority()));
+    return usedMetrics;
+  }
+
+  private List<ColumnInfo> buildColumnList(List<IEngineMetric> usedMetrics) {
+    List<ColumnInfo> columns = new ArrayList<>();
+
+    columns.add(
+        new ColumnInfo(
+            BaseMessages.getString(PKG, "PipelineLog.Column.TransformName"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            false,
+            true));
+
+    ColumnInfo copyColumn =
+        new ColumnInfo(
+            BaseMessages.getString(PKG, "PipelineLog.Column.Copynr"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            true,
+            true);
+    copyColumn.setAlignment(SWT.RIGHT);
+    columns.add(copyColumn);
+
+    for (IEngineMetric metric : usedMetrics) {
+      ColumnInfo column =
+          new ColumnInfo(metric.getHeader(), ColumnInfo.COLUMN_TYPE_TEXT, 
metric.isNumeric(), true);
+      column.setToolTip(metric.getTooltip());
+      IValueMeta stringMeta = new ValueMetaString(metric.getCode());
+      ValueMetaInteger valueMeta = new ValueMetaInteger(metric.getCode(), 15, 
0);
+      valueMeta.setConversionMask(METRICS_FORMAT);
+      stringMeta.setConversionMetadata(valueMeta);
+      column.setValueMeta(stringMeta);
+      column.setAlignment(SWT.RIGHT);
+      columns.add(column);
+    }
+
+    IValueMeta stringMeta = new ValueMetaString("string");
+
+    ColumnInfo durationColumn =
+        new ColumnInfo(
+            BaseMessages.getString(PKG, "PipelineLog.Column.Duration"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            false,
+            true);
+    durationColumn.setValueMeta(stringMeta);
+    durationColumn.setAlignment(SWT.RIGHT);
+    columns.add(durationColumn);
+
+    ValueMetaInteger speedMeta = new ValueMetaInteger("speed", 15, 0);
+    speedMeta.setConversionMask(" ###,###,###,##0");
+    stringMeta.setConversionMetadata(speedMeta);
+    ColumnInfo speedColumn =
+        new ColumnInfo(
+            BaseMessages.getString(PKG, "PipelineLog.Column.Speed"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            false,
+            true);
+    speedColumn.setValueMeta(stringMeta);
+    speedColumn.setAlignment(SWT.RIGHT);
+    columns.add(speedColumn);
+
+    columns.add(
+        new ColumnInfo(
+            BaseMessages.getString(PKG, "PipelineLog.Column.Status"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            false,
+            true));
 
-      // If the number of rows in the table is different then we need to 
remove all rows and
-      // rebuild.
-      // Otherwise we're just going to re-use the table items and put new 
values on the cells...
-      //
-      while (pipelineGridView.table.getItemCount() > 
componentStringsList.size()) {
-        pipelineGridView.table.remove(pipelineGridView.table.getItemCount() - 
1);
+    return columns;
+  }
+
+  private List<List<String>> buildComponentStringsList(
+      EngineMetrics engineMetrics,
+      List<IEngineComponent> shownComponents,
+      List<IEngineMetric> usedMetrics) {
+    List<List<String>> componentStringsList = new ArrayList<>();
+    int rowNum = 1;
+    for (IEngineComponent component : shownComponents) {
+      List<String> componentStrings = new ArrayList<>();
+      componentStrings.add(Integer.toString(rowNum++));
+      componentStrings.add(Const.NVL(component.getName(), ""));
+      componentStrings.add(Integer.toString(component.getCopyNr()));
+
+      for (IEngineMetric metric : usedMetrics) {
+        Long value = engineMetrics.getComponentMetric(component, metric);
+        componentStrings.add(value == null ? "" : formatMetric(value));
       }
+      componentStrings.add(calculateDuration(component));
+      
componentStrings.add(Const.NVL(engineMetrics.getComponentSpeedMap().get(component),
 ""));
+      
componentStrings.add(Const.NVL(engineMetrics.getComponentStatusMap().get(component),
 ""));
 
-      for (row = 0; row < componentStringsList.size(); row++) {
-        List<String> componentStrings = componentStringsList.get(row);
+      componentStringsList.add(componentStrings);
+    }
+    return componentStringsList;
+  }
 
-        TableItem item;
-        if (row < pipelineGridView.table.getItemCount()) {
-          item = pipelineGridView.table.getItem(row);
-        } else {
-          item = new TableItem(pipelineGridView.table, SWT.NONE);
-        }
+  private void recreateTableIfColumnsChanged(List<ColumnInfo> columns, int 
rowCount) {
+    if (!haveColumnsChanged(columns)) {
+      return;
+    }
+    pipelineGridView.dispose();
+    pipelineGridView =
+        new TableView(
+            pipelineGraph.getVariables(),
+            pipelineGridComposite,
+            SWT.NONE,
+            columns.toArray(new ColumnInfo[0]),
+            rowCount,
+            true,
+            null,
+            PropsUi.getInstance(),
+            true,
+            null,
+            false,
+            false); // no TableView toolbar; copy/filter are on our toolbar
+    pipelineGridView.setSortable(true);
+    attachMetricsTableListeners(pipelineGridView);
+    FormData fdView = new FormData();
+    fdView.left = new FormAttachment(0, 0);
+    fdView.right = new FormAttachment(100, 0);
+    fdView.top = new FormAttachment(toolbar, 0);
+    fdView.bottom = new FormAttachment(100, 0);
+    pipelineGridView.setLayoutData(fdView);
+    pipelineGridComposite.layout(true, true);
+  }
 
-        for (int col = 0; col < componentStrings.size(); col++) {
-          item.setText(col, componentStrings.get(col));
-        }
+  private void fillTableRows(List<List<String>> componentStringsList) {
+    while (pipelineGridView.table.getItemCount() > 
componentStringsList.size()) {
+      pipelineGridView.table.remove(pipelineGridView.table.getItemCount() - 1);
+    }
+    for (int row = 0; row < componentStringsList.size(); row++) {
+      List<String> componentStrings = componentStringsList.get(row);
+      TableItem item;
+      if (row < pipelineGridView.table.getItemCount()) {
+        item = pipelineGridView.table.getItem(row);
+      } else {
+        item = new TableItem(pipelineGridView.table, SWT.NONE);
+      }
+      for (int col = 0; col < componentStrings.size(); col++) {
+        item.setText(col, componentStrings.get(col));
       }
+    }
+  }
 
-      // Optimize the view...
-      //
-      pipelineGridView.optWidth(true);
+  /**
+   * Attaches all table listeners used by the metrics grid: sort capture, 
double-click to open
+   * transform, and edit toolbar button enable/disable. Call after creating or 
recreating the table.
+   */
+  private void attachMetricsTableListeners(TableView view) {
+    attachSortListener(view);
+    attachDoubleClickListener(view);
+    attachEditButtonStateListener(view);
+  }
 
-      previousRefreshColumns = columns;
-    } finally {
-      refreshViewLock.unlock();
+  /**
+   * Attach listener to column headers so we remember sort column/direction 
for the next refresh.
+   */
+  private void attachSortListener(TableView view) {
+    Table table = view.table;
+    for (int i = 0; i < table.getColumnCount(); i++) {
+      table.getColumn(i).addListener(SWT.Selection, e -> captureSortState());
+    }
+  }
+
+  /** On double-click, open the transform configuration for the selected row 
(by transform name). */
+  private void attachDoubleClickListener(TableView view) {
+    Table table = view.table;
+    table.addListener(SWT.DefaultSelection, e -> 
openTransformForSelectedRow());
+  }
+
+  /**
+   * Enable/disable the edit (open transform) toolbar button based on table 
row selection. TableView
+   * sets selection inside its MouseDown (editSelected -> setSelection); the 
Table often does not
+   * fire Selection until after mouse handling, so the first click does not 
run our Selection
+   * listener. We run updateEditButtonState on MouseDown via asyncExec so it 
runs after TableView
+   * has applied the selection.
+   */
+  private void attachEditButtonStateListener(TableView view) {
+    Table table = view.table;
+    table.addListener(SWT.Selection, e -> updateEditButtonState());
+    table.addListener(
+        SWT.MouseDown,
+        e -> {
+          if (table.isDisposed()) {
+            return;
+          }
+          hopGui.getDisplay().asyncExec(this::updateEditButtonState);
+        });
+    updateEditButtonState();
+  }
+
+  /**
+   * Enables or disables the open-transform toolbar button based on whether a 
table row is selected.
+   */
+  private void updateEditButtonState() {
+    if (toolbarWidget == null || toolbar == null || toolbar.isDisposed()) {
+      return;
+    }
+    boolean linesSelected =
+        pipelineGridView != null
+            && !pipelineGridView.isDisposed()
+            && pipelineGridView.table.getSelectionCount() > 0;
+    toolbarWidget.enableToolbarItem(TOOLBAR_ICON_OPEN_TRANSFORM, 
linesSelected);
+  }
+
+  /**
+   * Opens the transform configuration dialog for the selected table row. 
Reads the transform name
+   * from column 1 (see {@link #buildComponentStringsList}); finds the {@link 
TransformMeta} and
+   * calls {@link HopGuiPipelineGraph#editTransform(PipelineMeta, 
TransformMeta)}.
+   */
+  private void openTransformForSelectedRow() {
+    if (pipelineGridView == null || pipelineGridView.isDisposed()) {
+      return;
+    }
+    Table table = pipelineGridView.table;
+    TableItem[] selection = table.getSelection();
+    if (selection == null || selection.length == 0) {
+      return;
+    }
+    // Column 0 = #, column 1 = transform name (matches 
buildComponentStringsList)
+    String transformName = selection[0].getText(1);
+    if (Utils.isEmpty(transformName)) {
+      return;
+    }
+    PipelineMeta pipelineMeta = pipelineGraph.getPipelineMeta();
+    if (pipelineMeta == null) {
+      return;
+    }
+    TransformMeta transformMeta = pipelineMeta.findTransform(transformName);
+    if (transformMeta == null) {
+      return;
+    }
+    pipelineGraph.editTransform(pipelineMeta, transformMeta);
+  }
+
+  private void captureSortState() {
+    if (pipelineGridView == null || pipelineGridView.isDisposed()) {
+      return;
+    }
+    hopGui
+        .getDisplay()
+        .asyncExec(
+            () -> {
+              if (pipelineGridView == null || pipelineGridView.isDisposed()) {
+                return;
+              }
+              Table table = pipelineGridView.table;
+              TableColumn sortCol = table.getSortColumn();
+              if (sortCol != null) {
+                gridSortColumn = table.indexOf(sortCol);
+                gridSortDescending = (table.getSortDirection() == SWT.DOWN);
+              }
+            });
+  }
+
+  /**
+   * Sort the row data by the given column and direction before writing to the 
table. Column index 0
+   * = #, 1 = name, 2 = copy, 3+ = metrics, then duration, speed, status. 
Numeric columns (formatted
+   * with commas) are compared as numbers; others as strings.
+   */
+  private void sortComponentStringsByColumn(
+      List<List<String>> rows, int sortColumn, boolean descending) {
+    if (sortColumn < 0 || rows.isEmpty()) {
+      return;
+    }
+    int col = sortColumn;
+    boolean desc = descending;
+    rows.sort(
+        (a, b) -> {
+          String sa = col < a.size() ? Const.NVL(a.get(col), "") : "";
+          String sb = col < b.size() ? Const.NVL(b.get(col), "") : "";
+          int c = compareCellValues(sa, sb);
+          return desc ? -c : c;
+        });
+  }
+
+  private int compareCellValues(String a, String b) {
+    String sa = a != null ? a : "";
+    String sb = b != null ? b : "";
+    try {
+      long na = parseFormattedLong(sa);
+      long nb = parseFormattedLong(sb);
+      return Long.compare(na, nb);
+    } catch (NumberFormatException e) {
+      // not both numeric, compare as strings
+    }
+    return sa.compareToIgnoreCase(sb);
+  }
+
+  private static long parseFormattedLong(String s) {
+    if (s == null || s.isEmpty()) {
+      return 0L;
+    }
+    String trimmed = s.trim();
+    if (trimmed.isEmpty()) {
+      return 0L;
+    }
+    return Long.parseLong(trimmed.replace(",", ""));
+  }
+
+  /** Update the table header to show which column is sorted and in which 
direction. */
+  private void setSortIndicator() {
+    if (pipelineGridView == null || pipelineGridView.isDisposed()) {
+      return;
+    }
+    Table table = pipelineGridView.table;
+    if (gridSortColumn >= 0 && gridSortColumn < table.getColumnCount()) {
+      table.setSortColumn(table.getColumn(gridSortColumn));
+      table.setSortDirection(gridSortDescending ? SWT.DOWN : SWT.UP);
     }
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
index 41b7720563..11439a2b66 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
@@ -316,15 +316,21 @@ public class HopGuiPipelineLogDelegate {
       image = "ui/images/pause.svg",
       separator = true)
   public void pauseLog() {
-    ToolItem item = toolBarWidgets.findToolItem(TOOLBAR_ICON_LOG_PAUSE_RESUME);
     if (logBrowser.isPaused()) {
       logBrowser.setPaused(false);
-      item.setImage(GuiResource.getInstance().getImagePause());
-      item.setToolTipText(BaseMessages.getString(PKG, 
"PipelineLog.Dialog.Pause.Tooltip"));
+      toolBarWidgets.setToolbarItemImage(TOOLBAR_ICON_LOG_PAUSE_RESUME, 
"ui/images/pause.svg");
+      setPauseResumeTooltip("PipelineLog.Dialog.Pause.Tooltip");
     } else {
       logBrowser.setPaused(true);
-      item.setImage(GuiResource.getInstance().getImageRun());
-      item.setToolTipText(BaseMessages.getString(PKG, 
"PipelineLog.Dialog.Resume.Tooltip"));
+      toolBarWidgets.setToolbarItemImage(TOOLBAR_ICON_LOG_PAUSE_RESUME, 
"ui/images/run.svg");
+      setPauseResumeTooltip("PipelineLog.Dialog.Resume.Tooltip");
+    }
+  }
+
+  private void setPauseResumeTooltip(String messageKey) {
+    ToolItem item = toolBarWidgets.findToolItem(TOOLBAR_ICON_LOG_PAUSE_RESUME);
+    if (item != null && !item.isDisposed()) {
+      item.setToolTipText(BaseMessages.getString(PKG, messageKey));
     }
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
index 50964aea83..772a48deb6 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
@@ -48,7 +48,6 @@ import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.ToolBar;
-import org.eclipse.swt.widgets.ToolItem;
 
 @GuiPlugin(description = "Workflow Graph Log Delegate")
 public class HopGuiWorkflowLogDelegate {
@@ -297,13 +296,12 @@ public class HopGuiWorkflowLogDelegate {
       image = "ui/images/pause.svg",
       separator = true)
   public void pauseLog() {
-    ToolItem item = toolBarWidgets.findToolItem(TOOLBAR_ICON_LOG_PAUSE_RESUME);
     if (logBrowser.isPaused()) {
       logBrowser.setPaused(false);
-      item.setImage(GuiResource.getInstance().getImageRun());
+      toolBarWidgets.setToolbarItemImage(TOOLBAR_ICON_LOG_PAUSE_RESUME, 
"ui/images/pause.svg");
     } else {
       logBrowser.setPaused(true);
-      item.setImage(GuiResource.getInstance().getImagePause());
+      toolBarWidgets.setToolbarItemImage(TOOLBAR_ICON_LOG_PAUSE_RESUME, 
"ui/images/run.svg");
     }
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/selection/HopGuiSelectionTracker.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/selection/HopGuiSelectionTracker.java
index 6957873f95..151efe3e26 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/selection/HopGuiSelectionTracker.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/selection/HopGuiSelectionTracker.java
@@ -17,10 +17,18 @@
 
 package org.apache.hop.ui.hopgui.selection;
 
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
 /**
  * Tracks the last selected item type to help route keyboard shortcuts 
correctly. This is needed
  * when multiple components (like file explorer and pipeline graph) share the 
same keyboard
  * shortcuts but need to act on different types of items.
+ *
+ * <p>Listeners can register to be notified when a given selection type is set 
(e.g. pipeline graph
+ * selection changed).
  */
 public class HopGuiSelectionTracker {
 
@@ -40,8 +48,13 @@ public class HopGuiSelectionTracker {
 
   private SelectionType lastSelectionType = SelectionType.NONE;
 
+  private final Map<SelectionType, List<Runnable>> selectionListeners =
+      new EnumMap<>(SelectionType.class);
+
   private HopGuiSelectionTracker() {
-    // Singleton
+    for (SelectionType t : SelectionType.values()) {
+      selectionListeners.put(t, new ArrayList<>());
+    }
   }
 
   public static HopGuiSelectionTracker getInstance() {
@@ -52,12 +65,25 @@ public class HopGuiSelectionTracker {
   }
 
   /**
-   * Set the last selected item type
+   * Add a listener that runs when the given selection type is set.
+   *
+   * @param selectionType the type to listen for
+   * @param listener runnable to execute when 
setLastSelectionType(selectionType) is called
+   */
+  public void addSelectionListener(SelectionType selectionType, Runnable 
listener) {
+    selectionListeners.get(selectionType).add(listener);
+  }
+
+  /**
+   * Set the last selected item type and notify any listeners for this type.
    *
    * @param selectionType The type of item that was selected
    */
   public void setLastSelectionType(SelectionType selectionType) {
     this.lastSelectionType = selectionType;
+    for (Runnable listener : selectionListeners.get(selectionType)) {
+      listener.run();
+    }
   }
 
   /**
diff --git a/ui/src/main/java/org/apache/hop/ui/util/SwtSvgImageUtil.java 
b/ui/src/main/java/org/apache/hop/ui/util/SwtSvgImageUtil.java
index e27f3cc50e..7a5c3b80e6 100644
--- a/ui/src/main/java/org/apache/hop/ui/util/SwtSvgImageUtil.java
+++ b/ui/src/main/java/org/apache/hop/ui/util/SwtSvgImageUtil.java
@@ -224,8 +224,14 @@ public class SwtSvgImageUtil {
     return loadFromClassLoader(cl, location);
   }
 
-  /** Internal image loading from Hop's user.dir VFS. */
+  /**
+   * Internal image loading from Hop's user.dir VFS. Returns null if base is 
null or on any
+   * exception (e.g. VFS files-cache not set), so callers can fall back to 
other loaders.
+   */
   private static SwtUniversalImage loadFromBasedVFS(Display display, String 
location) {
+    if (base == null) {
+      return null;
+    }
     try {
       FileObject imageFileObject = 
HopVfs.getFileSystemManager().resolveFile(base, location);
       InputStream s = HopVfs.getInputStream(imageFileObject);
@@ -239,6 +245,11 @@ public class SwtSvgImageUtil {
       }
     } catch (FileSystemException ex) {
       return null;
+    } catch (RuntimeException ex) {
+      // e.g. NPE when VFS files-cache is not set 
(AbstractFileSystem.getFilesCache())
+      log.logDebug(
+          "VFS-based image load failed for [" + location + "], will try other 
loaders", ex);
+      return null;
     }
   }
 
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index c7a7081a4f..fef49ce307 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -220,6 +220,7 @@ PipelineLog.Button.LogSettings=\ &Log settings
 PipelineLog.Button.ShowErrorLines=\ &Show error lines 
 PipelineLog.Button.ShowOnlyActiveTransforms=Hide inactive
 PipelineLog.Button.ShowOnlySelectedTransforms=Show only selected transforms
+PipelineLog.Search.TransformName.Placeholder=Filter by transform name...
 PipelineLog.Column.Active=Active
 PipelineLog.Column.BuffersInput=Buffers Input
 PipelineLog.Column.BuffersOutput=Buffers Output

Reply via email to