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 a131da15a5 improve variable dialog, fixes #7623 (#7624)
a131da15a5 is described below

commit a131da15a5feb7d4a7d5e78d120cb89d9d5c49f8
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Jul 24 17:25:23 2026 +0200

    improve variable dialog, fixes #7623 (#7624)
---
 .../hop/ui/core/widget/ControlSpaceKeyAdapter.java | 662 ++++++++++++++++-----
 .../core/widget/messages/messages_en_US.properties |  10 +-
 .../ui/core/widget/ControlSpaceKeyAdapterTest.java | 121 ++++
 3 files changed, 639 insertions(+), 154 deletions(-)

diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapter.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapter.java
index 8a2fa2a2a4..a21922505b 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapter.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapter.java
@@ -17,21 +17,27 @@
 
 package org.apache.hop.ui.core.widget;
 
-import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.EnumMap;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.extension.ExtensionPointHandler;
 import org.apache.hop.core.extension.HopExtensionPoint;
 import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.core.variables.DescribedVariable;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.VariableRegistry;
 import org.apache.hop.core.variables.VariableScope;
 import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.util.EnvironmentUtils;
@@ -40,19 +46,35 @@ import org.eclipse.swt.custom.CCombo;
 import org.eclipse.swt.custom.StyledText;
 import org.eclipse.swt.events.KeyAdapter;
 import org.eclipse.swt.events.KeyEvent;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Font;
+import org.eclipse.swt.graphics.FontData;
+import org.eclipse.swt.graphics.GC;
 import org.eclipse.swt.graphics.Point;
 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.Control;
-import org.eclipse.swt.widgets.List;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.Text;
-import org.eclipse.swt.widgets.ToolTip;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeColumn;
+import org.eclipse.swt.widgets.TreeItem;
 
 public class ControlSpaceKeyAdapter extends KeyAdapter {
 
   private static final Class<?> PKG = ControlSpaceKeyAdapter.class;
 
+  /**
+   * Variable names matching one of these (case-insensitive) get their value 
masked in the popup.
+   */
+  private static final String[] SECRET_HINTS = {"password", "passwd", 
"secret", "token"};
+
+  private static final String MASKED_VALUE = "********";
+
   private final IGetCaretPosition getCaretPositionInterface;
 
   private final IInsertText insertTextInterface;
@@ -116,222 +138,556 @@ public class ControlSpaceKeyAdapter extends KeyAdapter {
     // CTRL-<SPACE> --> Insert a variable
     if (isHotKey(event)) {
       event.doit = false;
+      new VariablePopup().open();
+    }
+  }
+
+  /**
+   * The searchable, grouped variable picker shown on Ctrl/Cmd-Space. It 
replaces the old flat,
+   * unsearchable list: a search box (reusing the shared {@link 
SearchMatcher}), a tree of variables
+   * grouped by scope showing their resolved value, and a footer with the full 
value and description
+   * of the highlighted variable.
+   */
+  private final class VariablePopup {
+
+    private Shell shell;
+    private Text wSearch;
+    private Tree wTree;
+    private Label wDescription;
+
+    /** The smaller font the resolved value is drawn with under the variable 
name. */
+    private Font valueFont;
+
+    /**
+     * Two-line rows are painted by hand; that is not supported in Hop Web, 
which falls back to a
+     * two-column (name, value) layout.
+     */
+    private boolean ownerDrawn;
+
+    private final List<VarCandidate> candidates = buildCandidates(variables);
+    private final Map<TreeItem, VarCandidate> itemCandidates = new 
IdentityHashMap<>();
+    private final List<TreeItem> leafItems = new ArrayList<>();
+    private int position = -1;
 
+    void open() {
       // textField.setData(TRUE) indicates we have transitioned from the 
textbox to list mode...
-      // This will be set to false when the list selection has been processed
-      // and the list is being disposed of.
+      // This will be set to false when the selection has been processed and 
the popup is disposed.
       control.setData(Boolean.TRUE);
 
-      final int position;
       if (getCaretPositionInterface != null) {
         position = getCaretPositionInterface.getCaretPosition();
-      } else {
-        position = -1;
       }
 
-      // Drop down a list of variables...
-      //
       Rectangle bounds = control.getBounds();
       Point location;
       // StyledText is not supported in Hop Web
       if (!EnvironmentUtils.getInstance().isWeb() && control instanceof 
StyledText styledText) {
-        // Position the list under the caret
+        // Position the popup under the caret
         location = styledText.getLocationAtOffset(styledText.getCaretOffset());
         location.y += styledText.getLineHeight();
         location = styledText.toDisplay(location);
       } else {
-        // Position the list under the control
+        // Position the popup under the control
         location = GuiResource.calculateControlPosition(control);
         location.y += bounds.height;
       }
 
-      final Shell shell = new Shell(control.getShell(), SWT.NONE);
-      shell.setSize(bounds.width > 300 ? bounds.width : 300, 200);
-      shell.setLocation(location.x, location.y);
+      shell = new Shell(control.getShell(), SWT.NONE);
       shell.setLayout(new FormLayout());
-      final List list = new List(shell, SWT.SINGLE | SWT.H_SCROLL | 
SWT.V_SCROLL);
-      PropsUi.setLook(list);
-      list.setLayoutData(new FormDataBuilder().fullSize().result());
-      list.setItems(getVariableNames(variables));
-      final ToolTip toolTip = new ToolTip(list.getShell(), SWT.BALLOON);
-      toolTip.setAutoHide(true);
-
-      // Double-click: picks the variable
-      list.addListener(
-          SWT.DefaultSelection,
-          e -> applyChanges(shell, list, control, position, 
insertTextInterface));
-
-      // Select a variable name: display the value in a tool tip
-      list.addListener(
-          SWT.Selection,
-          e -> {
-            if (list.getSelectionCount() <= 0) {
-              return;
-            }
-            String name = list.getSelection()[0];
-            String value = variables.getVariable(name);
-            Rectangle shellBounds = shell.getBounds();
-            String message =
-                BaseMessages.getString(PKG, "TextVar.VariableValue.Message", 
name, value);
-            if (name.startsWith(Const.INTERNAL_VARIABLE_PREFIX)) {
-              message += BaseMessages.getString(PKG, 
"TextVar.InternalVariable.Message");
-            }
-            toolTip.setText(message);
-            toolTip.setVisible(false);
-            toolTip.setLocation(shell.getLocation().x, shell.getLocation().y + 
shellBounds.height);
-            toolTip.setVisible(true);
-          });
-
-      // Enter key pressed: picks the variable
-      list.addListener(
-          SWT.KeyDown,
-          e -> {
-            if (e.keyCode == SWT.CR
-                && ((e.stateMask & SWT.CONTROL) == 0)
-                && ((e.stateMask & SWT.SHIFT) == 0)) {
-              applyChanges(shell, list, control, position, 
insertTextInterface);
-            }
-          });
-
-      // Focus lost: close the list
-      list.addListener(
-          SWT.FocusOut,
-          e -> {
-            shell.dispose();
-            if (!control.isDisposed()) {
-              control.setData(Boolean.FALSE);
-            }
-          });
+      int width = Math.max(bounds.width, (int) 
(PropsUi.getInstance().getZoomFactor() * 450));
+      int height = (int) (PropsUi.getInstance().getZoomFactor() * 360);
+      shell.setSize(width, height);
+      shell.setLocation(location.x, location.y);
+
+      // Search box on top
+      wSearch =
+          new Text(
+              shell,
+              SWT.LEFT | SWT.BORDER | SWT.SINGLE | SWT.SEARCH | 
SWT.ICON_SEARCH | SWT.ICON_CANCEL);
+      wSearch.setMessage(BaseMessages.getString(PKG, 
"ControlSpaceKeyAdapter.Search.Placeholder"));
+      PropsUi.setLook(wSearch);
+      FormData fdSearch = new FormData();
+      fdSearch.left = new FormAttachment(0, 0);
+      fdSearch.right = new FormAttachment(100, 0);
+      fdSearch.top = new FormAttachment(0, 0);
+      wSearch.setLayoutData(fdSearch);
+
+      ownerDrawn = !EnvironmentUtils.getInstance().isWeb();
+
+      // Description footer at the bottom
+      wDescription = new Label(shell, SWT.LEFT | SWT.WRAP);
+      PropsUi.setLook(wDescription);
+      FormData fdDescription = new FormData();
+      fdDescription.left = new FormAttachment(0, PropsUi.getMargin());
+      fdDescription.right = new FormAttachment(100, -PropsUi.getMargin());
+      fdDescription.bottom = new FormAttachment(100, 0);
+      fdDescription.top =
+          new FormAttachment(100, -(int) 
(PropsUi.getInstance().getZoomFactor() * 60));
+      wDescription.setLayoutData(fdDescription);
+
+      // A separator between the list and the value/description footer
+      Label wSeparator = new Label(shell, SWT.SEPARATOR | SWT.HORIZONTAL);
+      FormData fdSeparator = new FormData();
+      fdSeparator.left = new FormAttachment(0, 0);
+      fdSeparator.right = new FormAttachment(100, 0);
+      fdSeparator.bottom = new FormAttachment(wDescription, 
-PropsUi.getMargin());
+      wSeparator.setLayoutData(fdSeparator);
+
+      // The smaller font used for the resolved value
+      FontData[] fontData = wSearch.getFont().getFontData();
+      valueFont =
+          new Font(
+              shell.getDisplay(),
+              fontData[0].getName(),
+              Math.max(8, fontData[0].getHeight() - 1),
+              SWT.NORMAL);
+
+      // The tree of variables in the middle
+      int treeStyle = SWT.SINGLE | SWT.FULL_SELECTION | SWT.V_SCROLL;
+      if (!ownerDrawn) {
+        treeStyle |= SWT.H_SCROLL;
+      }
+      wTree = new Tree(shell, treeStyle);
+      wTree.setHeaderVisible(false);
+      wTree.setLinesVisible(false);
+      PropsUi.setLook(wTree);
+      new TreeColumn(wTree, SWT.LEFT);
+      new TreeColumn(wTree, SWT.LEFT);
+      FormData fdTree = new FormData();
+      fdTree.left = new FormAttachment(0, 0);
+      fdTree.right = new FormAttachment(100, 0);
+      fdTree.top = new FormAttachment(wSearch, 0);
+      fdTree.bottom = new FormAttachment(wSeparator, 0);
+      wTree.setLayoutData(fdTree);
+
+      // Listeners
+      wSearch.addListener(SWT.Modify, e -> rebuild(wSearch.getText()));
+      wSearch.addListener(SWT.KeyDown, this::onSearchKey);
+      wTree.addListener(SWT.Selection, e -> syncSelectionFromTree());
+      wTree.addListener(SWT.DefaultSelection, e -> insertSelected());
+      wTree.addListener(SWT.Resize, e -> resizeColumns());
+      if (ownerDrawn) {
+        // Render each variable on two lines: name on top, resolved value 
smaller and muted below.
+        wTree.addListener(SWT.MeasureItem, this::onMeasureItem);
+        wTree.addListener(SWT.EraseItem, this::onEraseItem);
+        wTree.addListener(SWT.PaintItem, this::onPaintItem);
+      }
+      shell.addListener(SWT.Deactivate, e -> dispose());
+      shell.addListener(SWT.Traverse, this::onTraverse);
+
+      rebuild("");
 
       shell.open();
+      // The tree only has a real client area once it is laid out and shown.
+      resizeColumns();
+      wSearch.setFocus();
     }
-  }
 
-  private static void applyChanges(
-      Shell shell, List list, Control control, int position, IInsertText 
insertTextInterface) {
-    if (list.getSelection().length == 0) {
-      return;
+    /**
+     * When painting two-line rows a single full-width column is used; 
otherwise (Hop Web) the width
+     * is split between the variable name and its value.
+     */
+    private void resizeColumns() {
+      int available = wTree.getClientArea().width;
+      if (available <= 0) {
+        return;
+      }
+      if (ownerDrawn) {
+        wTree.getColumn(0).setWidth(available);
+        wTree.getColumn(1).setWidth(0);
+      } else {
+        int nameWidth = (int) (available * 0.45);
+        wTree.getColumn(0).setWidth(nameWidth);
+        wTree.getColumn(1).setWidth(available - nameWidth);
+      }
     }
-    String selection =
-        list.getSelection()[0].contains(Const.getDeprecatedPrefix())
-            ? list.getSelection()[0].replace(Const.getDeprecatedPrefix(), "")
-            : list.getSelection()[0];
-    String extra = "${" + selection + "}";
-    if (insertTextInterface != null) {
-      insertTextInterface.insertText(extra, position);
-    } else {
-      if (control.isDisposed()) {
+
+    /** Reserve room for two lines under each variable (name + value). */
+    private void onMeasureItem(Event event) {
+      if (!itemCandidates.containsKey((TreeItem) event.item)) {
+        return; // group header keeps the default single-line height
+      }
+      GC gc = event.gc;
+      gc.setFont(wTree.getFont());
+      int nameHeight = gc.getFontMetrics().getHeight();
+      gc.setFont(valueFont);
+      int valueHeight = gc.getFontMetrics().getHeight();
+      event.height = nameHeight + valueHeight + 4;
+    }
+
+    /** Suppress the default (single-line) text for leaves; we paint it 
ourselves. */
+    private void onEraseItem(Event event) {
+      if (itemCandidates.containsKey((TreeItem) event.item)) {
+        // Keep the background (including the selection highlight), drop the 
default foreground.
+        event.detail &= ~SWT.FOREGROUND;
+      }
+    }
+
+    /** Paint a variable on two lines: name on top, resolved value smaller and 
muted below. */
+    private void onPaintItem(Event event) {
+      if (event.index != 0) {
+        return; // everything is drawn in the first (full-width) column
+      }
+      VarCandidate candidate = itemCandidates.get((TreeItem) event.item);
+      if (candidate == null) {
+        return; // group header is painted by the default handler
+      }
+      GC gc = event.gc;
+      boolean selected = (event.detail & SWT.SELECTED) != 0;
+      Color nameColor;
+      Color valueColor;
+      if (selected) {
+        nameColor = 
wTree.getDisplay().getSystemColor(SWT.COLOR_LIST_SELECTION_TEXT);
+        valueColor = nameColor;
+      } else {
+        nameColor =
+            candidate.category == VarCategory.DEPRECATED
+                ? GuiResource.getInstance().getColorDarkGray()
+                : wTree.getForeground();
+        valueColor = GuiResource.getInstance().getColorDarkGray();
+      }
+
+      int x = event.x + 2;
+      gc.setFont(wTree.getFont());
+      gc.setForeground(nameColor);
+      int nameHeight = gc.getFontMetrics().getHeight();
+      gc.drawText(candidate.name, x, event.y + 1, SWT.DRAW_TRANSPARENT);
+
+      gc.setFont(valueFont);
+      gc.setForeground(valueColor);
+      gc.drawText(candidate.displayValue, x, event.y + 1 + nameHeight, 
SWT.DRAW_TRANSPARENT);
+    }
+
+    /** Rebuild the tree for the given filter text, grouping the matches by 
scope. */
+    private void rebuild(String filter) {
+      wTree.setRedraw(false);
+      wTree.removeAll();
+      itemCandidates.clear();
+      leafItems.clear();
+
+      SearchMatcher matcher =
+          StringUtils.isEmpty(filter) ? null : new SearchMatcher(filter, 
false, false, true);
+      Color mutedColor = GuiResource.getInstance().getColorDarkGray();
+
+      // Bucket the matching candidates per category, keeping a score for 
ranking.
+      Map<VarCategory, List<Scored>> byCategory = new 
EnumMap<>(VarCategory.class);
+      for (VarCandidate candidate : candidates) {
+        double score = 1.0;
+        if (matcher != null) {
+          score = Math.max(matcher.score(candidate.name), 
matcher.score(candidate.rawValue));
+          if (score <= 0.0) {
+            continue;
+          }
+        }
+        byCategory
+            .computeIfAbsent(candidate.category, k -> new ArrayList<>())
+            .add(new Scored(candidate, score));
+      }
+
+      for (VarCategory category : VarCategory.values()) {
+        List<Scored> scoredList = byCategory.get(category);
+        if (scoredList == null || scoredList.isEmpty()) {
+          continue;
+        }
+        scoredList.sort(
+            Comparator.comparingDouble((Scored s) -> s.score)
+                .reversed()
+                .thenComparing(s -> s.candidate.name, 
String.CASE_INSENSITIVE_ORDER));
+
+        TreeItem groupItem = new TreeItem(wTree, SWT.NONE);
+        groupItem.setText(
+            0, BaseMessages.getString(PKG, category.messageKey) + "  (" + 
scoredList.size() + ")");
+        groupItem.setFont(GuiResource.getInstance().getFontBold());
+
+        for (Scored scored : scoredList) {
+          VarCandidate candidate = scored.candidate;
+          TreeItem item = new TreeItem(groupItem, SWT.NONE);
+          item.setText(new String[] {candidate.name, candidate.displayValue});
+          if (candidate.category == VarCategory.DEPRECATED) {
+            item.setForeground(mutedColor);
+          }
+          if (!ownerDrawn) {
+            // Two-column fallback: make the value column visually secondary.
+            item.setFont(1, valueFont);
+            item.setForeground(1, mutedColor);
+          }
+          itemCandidates.put(item, candidate);
+          leafItems.add(item);
+        }
+        groupItem.setExpanded(true);
+      }
+
+      wTree.setRedraw(true);
+
+      if (leafItems.isEmpty()) {
+        wDescription.setText(
+            BaseMessages.getString(PKG, "ControlSpaceKeyAdapter.NoResults", 
filter));
+      } else {
+        selectLeaf(0);
+      }
+    }
+
+    /** Move the selection to the leaf variable at the given index (clamped) 
and reveal it. */
+    private void selectLeaf(int index) {
+      if (leafItems.isEmpty()) {
         return;
       }
+      int clamped = Math.max(0, Math.min(index, leafItems.size() - 1));
+      TreeItem item = leafItems.get(clamped);
+      wTree.setSelection(item);
+      wTree.showSelection();
+      updateDescription(itemCandidates.get(item));
+    }
 
-      if (list.getSelectionCount() <= 0) {
+    /** Keep the footer and the leaf cursor in step when the user clicks in 
the tree. */
+    private void syncSelectionFromTree() {
+      TreeItem[] selection = wTree.getSelection();
+      if (selection.length == 0) {
         return;
       }
-      if (control instanceof Text text) {
-        text.insert(extra);
-      } else if (control instanceof CCombo combo) {
-        // We can't know the location of the cursor yet. All we can do is 
overwrite.
-        combo.setText(extra);
-      } else if (control instanceof StyledText styledText) {
-        styledText.insert(extra);
+      VarCandidate candidate = itemCandidates.get(selection[0]);
+      if (candidate != null) {
+        updateDescription(candidate);
       }
     }
-    if (!shell.isDisposed()) {
-      shell.dispose();
+
+    private void updateDescription(VarCandidate candidate) {
+      if (candidate == null) {
+        wDescription.setText("");
+        return;
+      }
+      StringBuilder message = new StringBuilder();
+      message.append("${").append(candidate.name).append("} = 
").append(candidate.displayValue);
+      if (StringUtils.isNotEmpty(candidate.description)) {
+        message.append('\n').append(candidate.description);
+      }
+      if (candidate.name.startsWith(Const.INTERNAL_VARIABLE_PREFIX)) {
+        message
+            .append('\n')
+            .append(BaseMessages.getString(PKG, 
"TextVar.InternalVariable.Message"));
+      }
+      wDescription.setText(message.toString());
     }
-    if (!control.isDisposed()) {
-      control.setData(Boolean.FALSE);
+
+    private int currentLeafIndex() {
+      TreeItem[] selection = wTree.getSelection();
+      if (selection.length > 0) {
+        int index = leafItems.indexOf(selection[0]);
+        if (index >= 0) {
+          return index;
+        }
+      }
+      return 0;
     }
-  }
 
-  public static String[] getVariableNames(IVariables variables) {
-    // Deprecated variables will be displayed with the suffix (deprecated).
-    String[] variableNames = variables.getVariableNames();
-    for (int i = 0; i < variableNames.length; i++) {
-      for (String deprecatedName : 
VariableRegistry.getInstance().getDeprecatedVariableNames()) {
-        if (variableNames[i].equals(deprecatedName)) {
-          variableNames[i] = variableNames[i] + Const.getDeprecatedPrefix();
+    /** Drive the tree selection from the search box so the user never leaves 
the keyboard. */
+    private void onSearchKey(Event event) {
+      switch (event.keyCode) {
+        case SWT.ARROW_DOWN:
+          event.doit = false;
+          selectLeaf(currentLeafIndex() + 1);
+          break;
+        case SWT.ARROW_UP:
+          event.doit = false;
+          selectLeaf(currentLeafIndex() - 1);
           break;
+        case SWT.PAGE_DOWN:
+          event.doit = false;
+          selectLeaf(currentLeafIndex() + 10);
+          break;
+        case SWT.PAGE_UP:
+          event.doit = false;
+          selectLeaf(currentLeafIndex() - 10);
+          break;
+        case SWT.CR, SWT.KEYPAD_CR:
+          event.doit = false;
+          insertSelected();
+          break;
+        default:
+          break;
+      }
+    }
+
+    private void onTraverse(Event event) {
+      if (event.detail == SWT.TRAVERSE_ESCAPE) {
+        event.doit = false;
+        dispose();
+      }
+    }
+
+    private void insertSelected() {
+      TreeItem[] selection = wTree.getSelection();
+      VarCandidate candidate = selection.length > 0 ? 
itemCandidates.get(selection[0]) : null;
+      if (candidate == null && !leafItems.isEmpty()) {
+        candidate = itemCandidates.get(leafItems.get(0));
+      }
+      if (candidate == null) {
+        dispose();
+        return;
+      }
+
+      String extra = "${" + candidate.name + "}";
+      if (insertTextInterface != null) {
+        insertTextInterface.insertText(extra, position);
+      } else if (!control.isDisposed()) {
+        if (control instanceof Text text) {
+          text.insert(extra);
+        } else if (control instanceof CCombo combo) {
+          // We can't know the location of the cursor yet. All we can do is 
overwrite.
+          combo.setText(extra);
+        } else if (control instanceof StyledText styledText) {
+          styledText.insert(extra);
         }
       }
+      dispose();
     }
 
-    // Get the system properties to sort 'm at the back...
-    //
-    Properties systemProperties = System.getProperties();
+    private void dispose() {
+      if (shell != null && !shell.isDisposed()) {
+        shell.dispose();
+      }
+      if (valueFont != null && !valueFont.isDisposed()) {
+        valueFont.dispose();
+      }
+      if (!control.isDisposed()) {
+        control.setData(Boolean.FALSE);
+      }
+    }
+  }
 
-    // The internal Hop variables...
-    //
-    Set<String> hopVariablesSet = 
VariableRegistry.getInstance().getVariableNames();
+  /** A single variable ready to be shown in the picker: name, resolved value, 
scope and help. */
+  static final class VarCandidate {
+    final String name;
+    final String rawValue;
+    final String displayValue;
+    final String description;
+    final VarCategory category;
+
+    VarCandidate(
+        String name,
+        String rawValue,
+        String displayValue,
+        String description,
+        VarCategory category) {
+      this.name = name;
+      this.rawValue = rawValue;
+      this.displayValue = displayValue;
+      this.description = description;
+      this.category = category;
+    }
+  }
+
+  private static final class Scored {
+    final VarCandidate candidate;
+    final double score;
 
-    // The Deprecated variables...
-    Set<String> deprecatedSet =
-        new 
HashSet<>(VariableRegistry.getInstance().getDeprecatedVariableNames());
+    Scored(VarCandidate candidate, double score) {
+      this.candidate = candidate;
+      this.score = score;
+    }
+  }
 
-    // The Hop system settings variables
-    //
-    Set<String> hopSystemSettings =
-        VariableRegistry.getInstance().getVariableNames(VariableScope.SYSTEM);
+  /** The scope groups shown as headers in the picker, in display order 
(deprecated sinks last). */
+  enum VarCategory {
+    PROJECT("ControlSpaceKeyAdapter.Category.Project"),
+    CUSTOM("ControlSpaceKeyAdapter.Category.Custom"),
+    INTERNAL("ControlSpaceKeyAdapter.Category.Internal"),
+    APPLICATION("ControlSpaceKeyAdapter.Category.Application"),
+    SYSTEM("ControlSpaceKeyAdapter.Category.System"),
+    DEPRECATED("ControlSpaceKeyAdapter.Category.Deprecated");
 
-    Map<String, String> pluginsPrefixesMap = new HashMap<>();
+    final String messageKey;
 
+    VarCategory(String messageKey) {
+      this.messageKey = messageKey;
+    }
+  }
+
+  /**
+   * Build the list of variables to show in the picker: resolves every value, 
masks secrets, looks
+   * up descriptions and assigns each variable to a scope group.
+   */
+  static List<VarCandidate> buildCandidates(IVariables variables) {
+    VariableRegistry registry = VariableRegistry.getInstance();
+    Properties systemProperties = System.getProperties();
+    Set<String> deprecatedSet = new 
HashSet<>(registry.getDeprecatedVariableNames());
+    Set<String> systemSettings = 
registry.getVariableNames(VariableScope.SYSTEM);
+    Set<String> applicationSet =
+        registry.getVariableNames(VariableScope.APPLICATION, 
VariableScope.ENGINE);
+
+    Map<String, String> pluginPrefixMap = new HashMap<>();
     try {
       ExtensionPointHandler.callExtensionPoint(
           LogChannel.UI,
           variables,
           HopExtensionPoint.HopGuiGetControlSpaceSortOrderPrefix.name(),
-          pluginsPrefixesMap);
+          pluginPrefixMap);
     } catch (Exception e) {
       LogChannel.UI.logError(
           "Error calling extension point 
'HopGuiGetControlSpaceSortOrderPrefix'", e);
     }
 
-    Arrays.sort(variableNames);
-    return variableNames;
+    List<VarCandidate> result = new ArrayList<>();
+    for (String name : variables.getVariableNames()) {
+      String rawValue = Const.NVL(variables.getVariable(name), "");
+      String displayValue = isSecret(name) ? MASKED_VALUE : rawValue;
+      DescribedVariable described = registry.findDescribedVariable(name);
+      String description = described == null ? null : 
described.getDescription();
+      VarCategory category =
+          categorize(
+              name,
+              deprecatedSet,
+              pluginPrefixMap.keySet(),
+              systemSettings,
+              applicationSet,
+              systemProperties);
+      result.add(new VarCandidate(name, rawValue, displayValue, description, 
category));
+    }
+    return result;
   }
 
   /**
-   * Get a prefix to steer sorting of variables. Please note that variables 
can appear in multiple
-   * sets so we check back to front.
-   *
-   * @param variableName The variable name to prefix
-   * @param systemProperties
-   * @param hopVariablesSet
-   * @param deprecatedSet
-   * @return a prefixed variable name
+   * Assign a variable to a scope group. Checked most-specific first: a 
deprecated variable is shown
+   * as deprecated even if it is also internal, and a plugin-contributed 
(project/environment)
+   * variable wins over the generic buckets.
    */
-  private static String addPrefix(
-      String variableName,
-      Properties systemProperties,
-      Set<String> hopVariablesSet,
+  static VarCategory categorize(
+      String name,
       Set<String> deprecatedSet,
-      Set<String> hopSystemSettings,
-      Map<String, String> pluginsPrefixesMap) {
-    String prefix = "300_";
-    String systemValue = systemProperties.getProperty(variableName);
-    if (systemValue != null) {
-      // var1 is a system property... push it to the very end..
-      prefix = "900_";
+      Set<String> pluginVariables,
+      Set<String> systemSettings,
+      Set<String> applicationSet,
+      Properties systemProperties) {
+    if (deprecatedSet.contains(name)) {
+      return VarCategory.DEPRECATED;
+    }
+    if (pluginVariables.contains(name)) {
+      return VarCategory.PROJECT;
     }
-    if (hopVariablesSet.contains(variableName)) {
-      prefix = "800_";
+    if (name.startsWith(Const.INTERNAL_VARIABLE_PREFIX)) {
+      return VarCategory.INTERNAL;
     }
-    if (hopSystemSettings.contains(variableName)) {
-      prefix = "700_";
+    if (systemSettings.contains(name) || systemProperties.getProperty(name) != 
null) {
+      return VarCategory.SYSTEM;
     }
-    if (deprecatedSet.contains(variableName)) {
-      prefix = "600_";
+    if (applicationSet.contains(name)) {
+      return VarCategory.APPLICATION;
     }
-    if (variableName.startsWith(Const.INTERNAL_VARIABLE_PREFIX)) {
-      prefix = "500_";
+    return VarCategory.CUSTOM;
+  }
+
+  /** Whether a variable's value should be masked in the picker (looks like a 
credential). */
+  static boolean isSecret(String name) {
+    if (name == null) {
+      return false;
     }
-    // Finally allow plugins to override a sort order...
-    //
-    String pluginPrefix = pluginsPrefixesMap.get(variableName);
-    if (pluginPrefix != null) {
-      prefix = pluginPrefix;
+    String lower = name.toLowerCase();
+    for (String hint : SECRET_HINTS) {
+      if (lower.contains(hint)) {
+        return true;
+      }
     }
-    return prefix;
+    return false;
   }
 
   public void setVariables(IVariables vars) {
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
index 5757471c35..5ab1ef7367 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
@@ -43,7 +43,15 @@ ConditionEditor.SelectAField=Select a field:
 ConditionEditor.SelectFunction.Label=Select the function:
 ConditionEditor.SelectOperator.Label=Select the operator:
 ConditionEditor.StringNot=NOT
-ConditionEditor.StringUp=^^ UP ^^ 
+ConditionEditor.StringUp=^^ UP ^^
+ControlSpaceKeyAdapter.Category.Application=Application
+ControlSpaceKeyAdapter.Category.Custom=Custom
+ControlSpaceKeyAdapter.Category.Deprecated=Deprecated
+ControlSpaceKeyAdapter.Category.Internal=Hop internal
+ControlSpaceKeyAdapter.Category.Project=Project & environment
+ControlSpaceKeyAdapter.Category.System=System
+ControlSpaceKeyAdapter.NoResults=No variables match ''{0}''
+ControlSpaceKeyAdapter.Search.Placeholder=Search variables by name or value...
 MetadataElement.Edit.Tooltip=Edit this metadata element
 MetadataElement.New.Tooltip=Create a new metadata element
 MetadataElement.View.Tooltip=View the element in the metadata perspective
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapterTest.java
 
b/ui/src/test/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapterTest.java
new file mode 100644
index 0000000000..a82dbf5b71
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/core/widget/ControlSpaceKeyAdapterTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Properties;
+import java.util.Set;
+import org.apache.hop.ui.core.widget.ControlSpaceKeyAdapter.VarCategory;
+import org.junit.jupiter.api.Test;
+
+class ControlSpaceKeyAdapterTest {
+
+  private static final Set<String> EMPTY = Set.of();
+
+  @Test
+  void secretValuesAreMasked() {
+    assertTrue(ControlSpaceKeyAdapter.isSecret("MY_PASSWORD"));
+    assertTrue(ControlSpaceKeyAdapter.isSecret("db.secret.key"));
+    assertTrue(ControlSpaceKeyAdapter.isSecret("API_TOKEN"));
+    assertTrue(ControlSpaceKeyAdapter.isSecret("passwd_file"));
+  }
+
+  @Test
+  void nonSecretValuesAreNotMasked() {
+    assertFalse(ControlSpaceKeyAdapter.isSecret("PROJECT_HOME"));
+    
assertFalse(ControlSpaceKeyAdapter.isSecret("Internal.Pipeline.Filename.Name"));
+    assertFalse(ControlSpaceKeyAdapter.isSecret(null));
+  }
+
+  @Test
+  void deprecatedWinsOverEveryOtherCategory() {
+    // A deprecated variable is shown as deprecated even when it is also 
internal.
+    assertEquals(
+        VarCategory.DEPRECATED,
+        ControlSpaceKeyAdapter.categorize(
+            "Internal.Job.Filename.Directory",
+            Set.of("Internal.Job.Filename.Directory"),
+            EMPTY,
+            EMPTY,
+            EMPTY,
+            new Properties()));
+  }
+
+  @Test
+  void pluginVariablesAreProjectScopedAndWinOverInternal() {
+    assertEquals(
+        VarCategory.PROJECT,
+        ControlSpaceKeyAdapter.categorize(
+            "PROJECT_HOME", EMPTY, Set.of("PROJECT_HOME"), EMPTY, EMPTY, new 
Properties()));
+    // Plugin contribution is checked before the Internal.* rule.
+    assertEquals(
+        VarCategory.PROJECT,
+        ControlSpaceKeyAdapter.categorize(
+            "Internal.Custom", EMPTY, Set.of("Internal.Custom"), EMPTY, EMPTY, 
new Properties()));
+  }
+
+  @Test
+  void internalVariablesAreDetectedByPrefix() {
+    assertEquals(
+        VarCategory.INTERNAL,
+        ControlSpaceKeyAdapter.categorize(
+            "Internal.Pipeline.Filename.Name", EMPTY, EMPTY, EMPTY, EMPTY, new 
Properties()));
+  }
+
+  @Test
+  void systemPropertiesAndSystemSettingsAreSystemScoped() {
+    Properties props = new Properties();
+    props.setProperty("user.home", "/home/hop");
+    assertEquals(
+        VarCategory.SYSTEM,
+        ControlSpaceKeyAdapter.categorize("user.home", EMPTY, EMPTY, EMPTY, 
EMPTY, props));
+    assertEquals(
+        VarCategory.SYSTEM,
+        ControlSpaceKeyAdapter.categorize(
+            "HOP_SYSTEM_SETTING",
+            EMPTY,
+            EMPTY,
+            Set.of("HOP_SYSTEM_SETTING"),
+            EMPTY,
+            new Properties()));
+  }
+
+  @Test
+  void registeredApplicationVariablesAreApplicationScoped() {
+    assertEquals(
+        VarCategory.APPLICATION,
+        ControlSpaceKeyAdapter.categorize(
+            "HOP_CONFIG_FOLDER",
+            EMPTY,
+            EMPTY,
+            EMPTY,
+            Set.of("HOP_CONFIG_FOLDER"),
+            new Properties()));
+  }
+
+  @Test
+  void unknownVariablesFallBackToCustom() {
+    assertEquals(
+        VarCategory.CUSTOM,
+        ControlSpaceKeyAdapter.categorize(
+            "MY_OWN_VARIABLE", EMPTY, EMPTY, EMPTY, EMPTY, new Properties()));
+  }
+}


Reply via email to