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 8e0649b452 Issue #7906 : Add a description to environment 
configuration files (#8579)
8e0649b452 is described below

commit 8e0649b4529de15e3ba04957cbaf9ccc0250faaa
Author: Matt Casters <[email protected]>
AuthorDate: Fri Sep 25 10:44:28 2026 +0200

    Issue #7906 : Add a description to environment configuration files (#8579)
    
    Store an optional description next to the variables in an environment JSON 
file, edit it from the environment dialog, and show the file's variables on 
hover.
---
 .../core/config/DescribedVariablesConfigFile.java  |  32 +++
 .../config/DescribedVariablesConfigFileTest.java   |  90 ++++++++
 .../ROOT/pages/projects/projects-environments.adoc |  29 ++-
 .../environment/EnvironmentConfigFileSummary.java  | 235 ++++++++++++++++++++
 .../environment/LifecycleEnvironmentDialog.java    | 189 ++++++++++++++--
 .../environment/messages/messages_en_US.properties |   7 +
 .../EnvironmentConfigFileSummaryTest.java          | 244 +++++++++++++++++++++
 .../projects/util/ProjectsConfigHelperTest.java    |   7 +
 .../core/dialog/HopDescribedVariablesDialog.java   |  64 +++++-
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |   5 +
 .../core/dialog/messages/messages_en_US.properties |   2 +
 11 files changed, 881 insertions(+), 23 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/config/DescribedVariablesConfigFile.java
 
b/core/src/main/java/org/apache/hop/core/config/DescribedVariablesConfigFile.java
index 69e4976f08..31d19d2b4f 100644
--- 
a/core/src/main/java/org/apache/hop/core/config/DescribedVariablesConfigFile.java
+++ 
b/core/src/main/java/org/apache/hop/core/config/DescribedVariablesConfigFile.java
@@ -20,6 +20,9 @@ package org.apache.hop.core.config;
 import org.apache.hop.core.config.plugin.ConfigFile;
 
 public class DescribedVariablesConfigFile extends ConfigFile implements 
IConfigFile {
+  /** Optional note stored next to {@code variables} in an environment 
configuration file. */
+  public static final String HOP_DESCRIPTION_KEY = "description";
+
   private String configFilename;
 
   public DescribedVariablesConfigFile(String configFilename) {
@@ -45,4 +48,33 @@ public class DescribedVariablesConfigFile extends ConfigFile 
implements IConfigF
   public void setConfigFilename(String configFilename) {
     this.configFilename = configFilename;
   }
+
+  /**
+   * The file description, or null when the file has none.
+   *
+   * <p>Blank values are treated as absent. This is the description of the 
file itself, not the
+   * description of a variable inside it.
+   */
+  public String getDescription() {
+    Object value = configMap.get(HOP_DESCRIPTION_KEY);
+    if (value == null) {
+      return null;
+    }
+    String text = value.toString().trim();
+    return text.isEmpty() ? null : text;
+  }
+
+  /**
+   * Store or clear the file description.
+   *
+   * <p>A null or blank value removes the key so a file that never had a 
description is not given an
+   * empty one the next time it is written.
+   */
+  public void setDescription(String description) {
+    if (description == null || description.trim().isEmpty()) {
+      configMap.remove(HOP_DESCRIPTION_KEY);
+    } else {
+      configMap.put(HOP_DESCRIPTION_KEY, description.trim());
+    }
+  }
 }
diff --git 
a/core/src/test/java/org/apache/hop/core/config/DescribedVariablesConfigFileTest.java
 
b/core/src/test/java/org/apache/hop/core/config/DescribedVariablesConfigFileTest.java
index ee94d989e2..0452ab1ed7 100644
--- 
a/core/src/test/java/org/apache/hop/core/config/DescribedVariablesConfigFileTest.java
+++ 
b/core/src/test/java/org/apache/hop/core/config/DescribedVariablesConfigFileTest.java
@@ -19,11 +19,18 @@ package org.apache.hop.core.config;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.variables.DescribedVariable;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 class DescribedVariablesConfigFileTest {
 
+  @TempDir Path folder;
+
   @Test
   void filenameAndSerializer() {
     DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile("/tmp/hop-config.json");
@@ -33,4 +40,87 @@ class DescribedVariablesConfigFileTest {
     file.setConfigFilename("/other/path.json");
     assertEquals("/other/path.json", file.getConfigFilename());
   }
+
+  @Test
+  void descriptionRoundTripsWithVariables() throws Exception {
+    Path path = folder.resolve("env.json");
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(path.toString());
+    file.setDescription("  Warehouse connection  ");
+    file.setDescribedVariable(new DescribedVariable("DB_HOSTNAME", 
"localhost", "Warehouse host"));
+    file.saveToFile();
+
+    DescribedVariablesConfigFile loaded = new 
DescribedVariablesConfigFile(path.toString());
+    loaded.readFromFile();
+    assertEquals("Warehouse connection", loaded.getDescription());
+    assertEquals("localhost", 
loaded.findDescribedVariableValue("DB_HOSTNAME"));
+    assertEquals("Warehouse host", 
loaded.findDescribedVariable("DB_HOSTNAME").getDescription());
+  }
+
+  @Test
+  void readingVariablesKeepsTheFileDescription() throws Exception {
+    Path path = folder.resolve("env.json");
+    Files.writeString(
+        path,
+        """
+        {
+          "description" : "my environment config",
+          "variables" : [ { "name" : "TEST_ENV_VAR", "value" : "test_val", 
"description" : "" } ]
+        }
+        """);
+
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(path.toString());
+    file.readFromFile();
+    assertEquals("test_val", file.findDescribedVariableValue("TEST_ENV_VAR"));
+    assertEquals(1, file.getDescribedVariables().size());
+    assertEquals("my environment config", file.getDescription());
+
+    file.saveToFile();
+    DescribedVariablesConfigFile reloaded = new 
DescribedVariablesConfigFile(path.toString());
+    reloaded.readFromFile();
+    assertEquals("my environment config", reloaded.getDescription());
+    assertEquals("test_val", 
reloaded.findDescribedVariableValue("TEST_ENV_VAR"));
+  }
+
+  @Test
+  void savingVariablesDoesNotInventADescription() throws Exception {
+    Path path = folder.resolve("env.json");
+    Files.writeString(
+        path,
+        """
+        { "extra" : "keep", "variables" : [ { "name" : "A", "value" : "1", 
"description" : "d" } ] }
+        """);
+
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(path.toString());
+    file.readFromFile();
+    file.setDescribedVariables(file.getDescribedVariables());
+    file.saveToFile();
+
+    DescribedVariablesConfigFile loaded = new 
DescribedVariablesConfigFile(path.toString());
+    loaded.readFromFile();
+    assertNull(loaded.getDescription());
+    
assertNull(loaded.getConfigMap().get(DescribedVariablesConfigFile.HOP_DESCRIPTION_KEY));
+    assertEquals("keep", loaded.getConfigMap().get("extra"));
+    assertEquals("1", loaded.findDescribedVariableValue("A"));
+    assertEquals("d", loaded.findDescribedVariable("A").getDescription());
+  }
+
+  @Test
+  void blankDescriptionRemovesTheKeyAndKeepsOtherEntries() throws Exception {
+    Path path = folder.resolve("env.json");
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(path.toString());
+    file.setDescription("hello");
+    file.setDescribedVariable(new DescribedVariable("A", "1", ""));
+    file.getConfigMap().put("extra", "keep");
+    file.saveToFile();
+
+    file.setDescription("   ");
+    file.saveToFile();
+
+    DescribedVariablesConfigFile loaded = new 
DescribedVariablesConfigFile(path.toString());
+    loaded.readFromFile();
+    assertNull(loaded.getDescription());
+    
assertNull(loaded.getConfigMap().get(DescribedVariablesConfigFile.HOP_DESCRIPTION_KEY));
+    assertEquals("keep", loaded.getConfigMap().get("extra"));
+    assertEquals("1", loaded.findDescribedVariableValue("A"));
+  }
 }
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index a0e001764b..9af778be4a 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
@@ -143,11 +143,12 @@ 
image:hop-gui/environment/environment-dialog-configuration-files-tab.png[Environ
 [id="tab-env-props-files",cols="20%,45%,5%,5%,25%",options="header"]
 |===
 |Property|Description|Variables Supported|Mandatory|Default
-|Configuration files|One or more files that define environment-specific 
variables|Yes|No|
+|Configuration files|One or more files that define environment-specific 
variables. Each row is a filename and a description|Yes|No|
+|Description|Short note about that file. It is stored in the JSON file, not on 
the environment|No|No|
 |Import variables|Scan the project for `+${VARIABLE}+` expressions and add 
missing names to a configuration file|No|No|
 |Select...|Add an existing configuration file|No|No|
 |New...|Create a new configuration file and add it to the environment|No|No|
-|Edit...|Open the selected configuration file to set variable names, values 
and descriptions|No|No|
+|Edit...|Open the selected configuration file to set its description and the 
variable names, values and descriptions|No|No|
 |===
 
 Each environment contains one or more configuration files where you manage 
environment-specific variables.
@@ -159,10 +160,32 @@ See xref:best-practices/index.adoc[Best practices: 
security] and xref:hop-gui/pe
 
 image:hop-gui/environment/environment-files.png[Environment configuration 
files,width="90%"]
 
-*Edit...* opens the described variables dialog for the selected file:
+*Edit...* opens the described variables dialog for the selected file.
+The description at the top of that dialog is the same file description as the 
*Description* column.
 
 image::hop-gui/environment/environment-variables.png[Environment 
Variables,width="80%"]
 
+The description is written into the configuration file when you confirm the 
environment dialog.
+A file whose description did not change is left as it is.
+A file that does not exist yet is created when the description is not empty.
+Hover a row to see that description and the variables stored in the file.
+Values are shown as they are written, not with variables resolved.
+The value is hidden when the variable name contains `password`, `passwd`, 
`secret`, `token` or `credential`, and when the value is an encrypted Hop 
password.
+
+A configuration file looks like this:
+
+[source,json]
+----
+{
+  "description" : "Warehouse connection for this environment",
+  "variables" : [ {
+    "name" : "DB_HOSTNAME",
+    "value" : "db.example.com",
+    "description" : "Warehouse host"
+  } ]
+}
+----
+
 When you browse for a file or directory in Hop Gui with a project active, 
paths under a matching path-like variable are rewritten automatically. For 
example, `+${PROJECT_HOME}+` is used for files under the project home, and an 
environment variable such as `SOURCE_FILES=/data/incoming` (or 
`SOURCE_FILES=~/incoming`) rewrites a selection under that folder to 
`+${SOURCE_FILES}/…+`. If more than one variable matches, the longest (most 
specific) path wins. The same applies to path variables d [...]
 
 After creating an environment the user interface will switch to it.
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentConfigFileSummary.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentConfigFileSummary.java
new file mode 100644
index 0000000000..99ca327e03
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentConfigFileSummary.java
@@ -0,0 +1,235 @@
+/*
+ * 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.projects.environment;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.DescribedVariablesConfigFile;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+
+/**
+ * Reads an environment configuration file for the environment dialog: its 
description, the
+ * variables to show on hover, and a description-only save that leaves the 
rest of the file alone.
+ */
+public final class EnvironmentConfigFileSummary {
+  private static final Class<?> PKG = EnvironmentConfigFileSummary.class;
+
+  public static final int MAX_VARIABLES_IN_TOOLTIP = 40;
+  public static final String MASKED_VALUE = "********";
+
+  private static final Pattern SECRET_NAME =
+      Pattern.compile("(?i).*(password|passwd|secret|token|credential).*");
+
+  private enum State {
+    OK,
+    MISSING,
+    UNREADABLE
+  }
+
+  private final String description;
+  private final List<DescribedVariable> variables;
+  private final State state;
+
+  private EnvironmentConfigFileSummary(
+      String description, List<DescribedVariable> variables, State state) {
+    this.description = description;
+    this.variables = variables;
+    this.state = state;
+  }
+
+  /**
+   * Read the configuration file at a resolved path.
+   *
+   * @param resolvedFilename absolute or local path with variables already 
resolved. Empty means the
+   *     row has no file yet.
+   * @return a summary. A missing or unreadable file does not throw.
+   */
+  public static EnvironmentConfigFileSummary read(String resolvedFilename) {
+    if (StringUtils.isEmpty(resolvedFilename)) {
+      return new EnvironmentConfigFileSummary(null, List.of(), State.MISSING);
+    }
+    try {
+      if (!HopVfs.fileExists(resolvedFilename)) {
+        return new EnvironmentConfigFileSummary(null, List.of(), 
State.MISSING);
+      }
+    } catch (Exception e) {
+      return new EnvironmentConfigFileSummary(null, List.of(), 
State.UNREADABLE);
+    }
+    try {
+      DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(resolvedFilename);
+      file.readFromFile();
+      // Description first: getDescribedVariables() can replace the map with a 
nested "config".
+      String description = file.getDescription();
+      return new EnvironmentConfigFileSummary(description, 
copyVariables(file), State.OK);
+    } catch (Exception e) {
+      return new EnvironmentConfigFileSummary(null, List.of(), 
State.UNREADABLE);
+    }
+  }
+
+  /**
+   * @return the description stored in the file, or null when there is none or 
the file was not read
+   */
+  public String getDescription() {
+    return description;
+  }
+
+  /**
+   * Hover text for one configuration-file row.
+   *
+   * @param descriptionFromCell the description currently shown in the table, 
which may not be saved
+   *     yet. The stored variable values are shown as written; they are not 
resolved.
+   */
+  public String tooltip(String descriptionFromCell) {
+    List<String> lines = new ArrayList<>();
+    String descriptionText = normalize(descriptionFromCell);
+    if (!descriptionText.isEmpty()) {
+      lines.add(descriptionText);
+    }
+
+    List<String> body = new ArrayList<>();
+    if (state == State.MISSING) {
+      
body.add(message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.Missing"));
+    } else if (state == State.UNREADABLE) {
+      
body.add(message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.Unreadable"));
+    } else if (variables.isEmpty()) {
+      
body.add(message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.NoVariables"));
+    } else {
+      int limit = Math.min(MAX_VARIABLES_IN_TOOLTIP, variables.size());
+      for (int i = 0; i < limit; i++) {
+        body.add(formatVariable(variables.get(i)));
+      }
+      if (variables.size() > MAX_VARIABLES_IN_TOOLTIP) {
+        body.add(
+            BaseMessages.getString(
+                PKG,
+                "LifecycleEnvironmentDialog.ConfigFile.ToolTip.More",
+                Integer.toString(variables.size() - 
MAX_VARIABLES_IN_TOOLTIP)));
+      }
+    }
+
+    if (!body.isEmpty()) {
+      if (!lines.isEmpty()) {
+        lines.add("");
+      }
+      lines.addAll(body);
+    }
+    return String.join("\n", lines);
+  }
+
+  /**
+   * Write {@code cellDescription} into the file when it differs from what is 
already stored.
+   *
+   * <p>An unchanged description does not rewrite the file. A missing file is 
created only when the
+   * description is not blank, with an empty variable list. A file that exists 
but cannot be read is
+   * left alone; a non-blank new description is an error so the dialog can 
stay open.
+   *
+   * @return true when the file was written
+   */
+  public static boolean saveDescriptionIfChanged(String resolvedFilename, 
String cellDescription)
+      throws HopException {
+    if (StringUtils.isEmpty(resolvedFilename)) {
+      return false;
+    }
+    String requested = normalize(cellDescription);
+    boolean exists;
+    try {
+      exists = HopVfs.fileExists(resolvedFilename);
+    } catch (Exception e) {
+      if (requested.isEmpty()) {
+        return false;
+      }
+      throw cannotRead(resolvedFilename, e);
+    }
+
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(resolvedFilename);
+    if (exists) {
+      try {
+        file.readFromFile();
+      } catch (Exception e) {
+        if (requested.isEmpty()) {
+          return false;
+        }
+        throw cannotRead(resolvedFilename, e);
+      }
+      // Compare without calling getDescribedVariables(), which rewrites the 
variable list.
+      if (requested.equals(normalize(file.getDescription()))) {
+        return false;
+      }
+      file.setDescription(requested);
+      file.saveToFile();
+      return true;
+    }
+
+    if (requested.isEmpty()) {
+      return false;
+    }
+    file.setDescription(requested);
+    file.setDescribedVariables(new ArrayList<>());
+    file.saveToFile();
+    return true;
+  }
+
+  private static HopException cannotRead(String resolvedFilename, Exception 
cause) {
+    return new HopException(
+        "Could not read configuration file '" + resolvedFilename + "' to 
update its description",
+        cause);
+  }
+
+  private static List<DescribedVariable> 
copyVariables(DescribedVariablesConfigFile file) {
+    List<DescribedVariable> copy = new ArrayList<>();
+    for (DescribedVariable variable : file.getDescribedVariables()) {
+      if (variable != null && StringUtils.isNotBlank(variable.getName())) {
+        copy.add(new DescribedVariable(variable));
+      }
+    }
+    return copy;
+  }
+
+  private static String formatVariable(DescribedVariable variable) {
+    String value = maskValue(variable) ? MASKED_VALUE : 
Const.NVL(variable.getValue(), "");
+    String line = variable.getName() + " = " + value;
+    if (StringUtils.isNotBlank(variable.getDescription())) {
+      line = line + " (" + variable.getDescription().trim() + ")";
+    }
+    return line;
+  }
+
+  private static boolean maskValue(DescribedVariable variable) {
+    String name = variable.getName();
+    if (name != null && SECRET_NAME.matcher(name).matches()) {
+      return true;
+    }
+    String value = variable.getValue();
+    return value != null && value.startsWith(Encr.PASSWORD_ENCRYPTED_PREFIX);
+  }
+
+  private static String message(String key) {
+    return BaseMessages.getString(PKG, key);
+  }
+
+  private static String normalize(String value) {
+    return value == null ? "" : value.trim();
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
index 8c820c1c0c..a120bac114 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
@@ -18,6 +18,7 @@
 package org.apache.hop.projects.environment;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
@@ -57,6 +58,8 @@ import org.apache.hop.ui.util.HelpUtils;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
 import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.events.ModifyEvent;
+import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
@@ -74,6 +77,12 @@ public class LifecycleEnvironmentDialog extends Dialog {
   private static final Class<?> PKG = LifecycleEnvironmentDialog.class;
   public static final String CONST_ERROR = "Error";
 
+  private static final int COL_FILENAME = 1;
+  private static final int COL_DESCRIPTION = 2;
+
+  /** Resolved path last read into this row, so leaving the filename cell does 
not reload it. */
+  private static final String DATA_RESOLVED_FILENAME = 
"resolvedConfigFilename";
+
   private final LifecycleEnvironment environment;
 
   private String returnValue;
@@ -108,6 +117,11 @@ public class LifecycleEnvironmentDialog extends Dialog {
   /** Localized purpose label → fixed English suffix (for new-environment name 
suggestion). */
   private Map<String, String> knownPurposeSuffixes;
 
+  /**
+   * Resolved configuration-file path → last read, so a description edit does 
not re-read the file.
+   */
+  private final Map<String, EnvironmentConfigFileSummary> configFileSummaries 
= new HashMap<>();
+
   public LifecycleEnvironmentDialog(
       Shell parent, LifecycleEnvironment environment, IVariables variables) {
     super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
@@ -347,16 +361,23 @@ public class LifecycleEnvironmentDialog extends Dialog {
     wbSelect.setLayoutData(fdAdd);
     wbSelect.addListener(SWT.Selection, this::addConfigFile);
 
-    ColumnInfo[] columnInfo =
-        new ColumnInfo[] {
-          new ColumnInfo(
-              BaseMessages.getString(PKG, 
"LifecycleEnvironmentDialog.DetailTable.Label.Filename"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              false,
-              false),
-        };
-    columnInfo[0].setUsingVariables(true);
-    columnInfo[0].setNamingSchemeType(NamingSchemeTypes.FILE);
+    ColumnInfo filenameColumn =
+        new ColumnInfo(
+            BaseMessages.getString(PKG, 
"LifecycleEnvironmentDialog.DetailTable.Label.Filename"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            false,
+            false);
+    filenameColumn.setUsingVariables(true);
+    filenameColumn.setNamingSchemeType(NamingSchemeTypes.FILE);
+    ColumnInfo descriptionColumn =
+        new ColumnInfo(
+            BaseMessages.getString(PKG, 
"LifecycleEnvironmentDialog.DetailTable.Label.Description"),
+            ColumnInfo.COLUMN_TYPE_TEXT,
+            false,
+            false);
+    descriptionColumn.setToolTip(
+        BaseMessages.getString(PKG, 
"LifecycleEnvironmentDialog.DetailTable.ToolTip.Description"));
+    ColumnInfo[] columnInfo = new ColumnInfo[] {filenameColumn, 
descriptionColumn};
 
     wConfigFiles =
         new TableView(
@@ -368,6 +389,10 @@ public class LifecycleEnvironmentDialog extends Dialog {
             null,
             props);
     PropsUi.setLook(wConfigFiles);
+    wConfigFiles.setContentListener(this::onConfigFileCellChanged);
+    // SWT 3.134 has no TableItem.setToolTipText. The table tooltip is set 
from the row under the
+    // pointer. Clearing it first is what makes GTK replace the previous row's 
text.
+    wConfigFiles.table.addListener(SWT.MouseHover, this::onConfigFileHover);
     FormData fdConfigFiles = new FormData();
     fdConfigFiles.left = new FormAttachment(0, 0);
     fdConfigFiles.right = new FormAttachment(wbImportVariables, -2 * margin);
@@ -404,11 +429,12 @@ public class LifecycleEnvironmentDialog extends Dialog {
       if (index < 0) {
         return;
       }
-      String configFilename = wConfigFiles.getItem(index, 1);
+      String configFilename = wConfigFiles.getItem(index, COL_FILENAME);
       if (StringUtils.isEmpty(configFilename)) {
         return;
       }
       String realConfigFilename = variables.resolve(configFilename);
+      String cellDescription = wConfigFiles.getItem(index, COL_DESCRIPTION);
 
       DescribedVariablesConfigFile variablesConfigFile =
           new DescribedVariablesConfigFile(realConfigFilename);
@@ -425,10 +451,15 @@ public class LifecycleEnvironmentDialog extends Dialog {
       } else {
         variablesConfigFile.readFromFile();
       }
+      // An unsaved description in the table has to win over the file, or Edit 
would drop it.
+      if (!sameDescription(variablesConfigFile.getDescription(), 
cellDescription)) {
+        variablesConfigFile.setDescription(cellDescription);
+      }
 
       boolean changed = HopGui.editConfigFile(shell, realConfigFilename, 
variablesConfigFile, null);
       if (changed) {
         needingEnvironmentRefresh = true;
+        loadConfigFileIntoRow(wConfigFiles.table.getItem(index));
       }
 
     } catch (Exception e) {
@@ -447,7 +478,8 @@ public class LifecycleEnvironmentDialog extends Dialog {
             true);
     if (configFile != null) {
       TableItem item = new TableItem(wConfigFiles.table, SWT.NONE);
-      item.setText(1, configFile);
+      item.setText(COL_FILENAME, configFile);
+      loadConfigFileIntoRow(item);
       wConfigFiles.removeEmptyRows();
       wConfigFiles.setRowNums();
       wConfigFiles.optWidth(true);
@@ -491,7 +523,8 @@ public class LifecycleEnvironmentDialog extends Dialog {
               true);
       if (configFile != null) {
         TableItem item = new TableItem(wConfigFiles.table, SWT.NONE);
-        item.setText(1, configFile);
+        item.setText(COL_FILENAME, configFile);
+        loadConfigFileIntoRow(item);
         wConfigFiles.removeEmptyRows();
         wConfigFiles.setRowNums();
         wConfigFiles.optWidth(true);
@@ -538,7 +571,10 @@ public class LifecycleEnvironmentDialog extends Dialog {
 
       List<String> configurationFiles = new ArrayList<>();
       for (TableItem item : wConfigFiles.getNonEmptyItems()) {
-        configurationFiles.add(item.getText(1));
+        String filename = item.getText(COL_FILENAME);
+        if (StringUtils.isNotEmpty(filename)) {
+          configurationFiles.add(filename);
+        }
       }
 
       List<DescribedVariable> proposed =
@@ -584,21 +620,28 @@ public class LifecycleEnvironmentDialog extends Dialog {
 
       // Ensure the path is listed on the environment
       boolean alreadyListed = false;
+      TableItem savedItem = null;
       for (TableItem item : wConfigFiles.getNonEmptyItems()) {
-        if (configFilename.equals(item.getText(1))
-            || realConfigFilename.equals(variables.resolve(item.getText(1)))) {
+        if (configFilename.equals(item.getText(COL_FILENAME))
+            || 
realConfigFilename.equals(variables.resolve(item.getText(COL_FILENAME)))) {
           alreadyListed = true;
+          savedItem = item;
           break;
         }
       }
       if (!alreadyListed) {
         TableItem item = new TableItem(wConfigFiles.table, SWT.NONE);
-        item.setText(1, configFilename);
+        item.setText(COL_FILENAME, configFilename);
+        savedItem = item;
         wConfigFiles.removeEmptyRows();
         wConfigFiles.setRowNums();
         wConfigFiles.optWidth(true);
         wConfigFiles.table.setSelection(item);
       }
+      if (savedItem != null) {
+        // Keep a description the user typed and has not saved; the file now 
has the new variables.
+        rereadConfigFileSummary(savedItem);
+      }
 
       needingEnvironmentRefresh = true;
 
@@ -705,6 +748,7 @@ public class LifecycleEnvironmentDialog extends Dialog {
             "Sorry, renaming environment '" + originalName + "' is not 
supported.");
       }
 
+      saveConfigFileDescriptions();
       getInfo(environment);
       if (dialogExtension != null) {
         
dialogExtension.getContext().setProjectName(environment.getProjectName());
@@ -807,10 +851,12 @@ public class LifecycleEnvironmentDialog extends Dialog {
     }
 
     wConfigFiles.table.removeAll();
+    configFileSummaries.clear();
     for (int i = 0; i < environment.getConfigurationFiles().size(); i++) {
       String configurationFile = environment.getConfigurationFiles().get(i);
       TableItem item = new TableItem(wConfigFiles.table, SWT.NONE);
-      item.setText(1, Const.NVL(configurationFile, ""));
+      item.setText(COL_FILENAME, Const.NVL(configurationFile, ""));
+      loadConfigFileIntoRow(item);
     }
     if (environment.getConfigurationFiles().isEmpty()) {
       new TableItem(wConfigFiles.table, SWT.NONE);
@@ -881,10 +927,115 @@ public class LifecycleEnvironmentDialog extends Dialog {
 
     env.getConfigurationFiles().clear();
     for (TableItem item : wConfigFiles.getNonEmptyItems()) {
-      env.getConfigurationFiles().add(item.getText(1));
+      String filename = item.getText(COL_FILENAME);
+      if (StringUtils.isNotEmpty(filename)) {
+        env.getConfigurationFiles().add(filename);
+      }
+    }
+  }
+
+  /**
+   * SWT's {@code ModifyEvent} no longer carries the cell coordinates, so look 
at every row. A
+   * description edit leaves the resolved path alone and is picked up on the 
next hover.
+   */
+  private void onConfigFileCellChanged(ModifyEvent event) {
+    if (wConfigFiles == null || wConfigFiles.table.isDisposed()) {
+      return;
+    }
+    for (TableItem item : wConfigFiles.table.getItems()) {
+      if (item.isDisposed()) {
+        continue;
+      }
+      String filename = item.getText(COL_FILENAME);
+      String resolved = StringUtils.isEmpty(filename) ? "" : 
variables.resolve(filename);
+      String previous = Const.NVL((String) 
item.getData(DATA_RESOLVED_FILENAME), "");
+      if (!resolved.equals(previous)) {
+        loadConfigFileIntoRow(item);
+      }
+    }
+  }
+
+  private void onConfigFileHover(Event event) {
+    if (wConfigFiles == null || wConfigFiles.table.isDisposed()) {
+      return;
+    }
+    TableItem item = wConfigFiles.table.getItem(new Point(event.x, event.y));
+    String tip = "";
+    if (item != null && !item.isDisposed() && 
StringUtils.isNotEmpty(item.getText(COL_FILENAME))) {
+      String resolved = variables.resolve(item.getText(COL_FILENAME));
+      EnvironmentConfigFileSummary summary = configFileSummaries.get(resolved);
+      if (summary == null) {
+        summary = EnvironmentConfigFileSummary.read(resolved);
+        configFileSummaries.put(resolved, summary);
+        item.setData(DATA_RESOLVED_FILENAME, resolved);
+      }
+      tip = summary.tooltip(item.getText(COL_DESCRIPTION));
+    }
+    wConfigFiles.table.setToolTipText("");
+    wConfigFiles.table.setToolTipText(tip);
+  }
+
+  /** Read the file named on the row and show its description. */
+  private void loadConfigFileIntoRow(TableItem item) {
+    if (item == null || item.isDisposed()) {
+      return;
+    }
+    String filename = item.getText(COL_FILENAME);
+    if (StringUtils.isEmpty(filename)) {
+      item.setData(DATA_RESOLVED_FILENAME, "");
+      return;
+    }
+    String resolved = variables.resolve(filename);
+    EnvironmentConfigFileSummary summary = 
EnvironmentConfigFileSummary.read(resolved);
+    configFileSummaries.put(resolved, summary);
+    item.setData(DATA_RESOLVED_FILENAME, resolved);
+    item.setText(COL_DESCRIPTION, Const.NVL(summary.getDescription(), ""));
+  }
+
+  /** Re-read the file after its variables changed, keeping the description 
typed in the cell. */
+  private void rereadConfigFileSummary(TableItem item) {
+    if (item == null || item.isDisposed()) {
+      return;
+    }
+    String filename = item.getText(COL_FILENAME);
+    if (StringUtils.isEmpty(filename)) {
+      item.setData(DATA_RESOLVED_FILENAME, "");
+      return;
+    }
+    String resolved = variables.resolve(filename);
+    configFileSummaries.remove(resolved);
+    EnvironmentConfigFileSummary summary = 
EnvironmentConfigFileSummary.read(resolved);
+    configFileSummaries.put(resolved, summary);
+    item.setData(DATA_RESOLVED_FILENAME, resolved);
+  }
+
+  /**
+   * Write description edits back into the configuration files. A file whose 
description did not
+   * change is not rewritten.
+   */
+  private void saveConfigFileDescriptions() throws HopException {
+    for (TableItem item : wConfigFiles.getNonEmptyItems()) {
+      String filename = item.getText(COL_FILENAME);
+      if (StringUtils.isEmpty(filename)) {
+        continue;
+      }
+      String resolved = variables.resolve(filename);
+      try {
+        EnvironmentConfigFileSummary.saveDescriptionIfChanged(
+            resolved, item.getText(COL_DESCRIPTION));
+      } catch (HopException e) {
+        throw new HopException(
+            BaseMessages.getString(
+                PKG, 
"LifecycleEnvironmentDialog.ConfigFile.SaveDescription.Error", resolved),
+            e);
+      }
     }
   }
 
+  private static boolean sameDescription(String stored, String cell) {
+    return StringUtils.equals(StringUtils.trimToEmpty(stored), 
StringUtils.trimToEmpty(cell));
+  }
+
   /**
    * Gets needingEnvironmentRefresh
    *
diff --git 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
index 041a367a3f..250577c415 100644
--- 
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
+++ 
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
@@ -20,6 +20,13 @@ LifecycleEnvironmentDialog.Button.ImportVariables=Import 
variables
 LifecycleEnvironmentDialog.Button.New=New...
 LifecycleEnvironmentDialog.Button.Select=Select...
 LifecycleEnvironmentDialog.DetailTable.Label.Filename=Filename
+LifecycleEnvironmentDialog.DetailTable.Label.Description=Description
+LifecycleEnvironmentDialog.DetailTable.ToolTip.Description=Short note stored 
in the configuration file, next to its variables
+LifecycleEnvironmentDialog.ConfigFile.ToolTip.Unreadable=Could not read this 
configuration file.
+LifecycleEnvironmentDialog.ConfigFile.ToolTip.Missing=This configuration file 
does not exist yet.
+LifecycleEnvironmentDialog.ConfigFile.ToolTip.NoVariables=No variables defined.
+LifecycleEnvironmentDialog.ConfigFile.ToolTip.More=\u2026 and {0} more
+LifecycleEnvironmentDialog.ConfigFile.SaveDescription.Error=Could not save the 
description for configuration file:\n{0}
 LifecycleEnvironmentDialog.Group.Label.ConfigurationFiles=Configuration files:
 LifecycleEnvironmentDialog.Label.EnvironmentName=Name
 LifecycleEnvironmentDialog.Label.EnvironmentPurpose=Purpose
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentConfigFileSummaryTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentConfigFileSummaryTest.java
new file mode 100644
index 0000000000..2e194d5614
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentConfigFileSummaryTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.projects.environment;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.config.DescribedVariablesConfigFile;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.i18n.BaseMessages;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class EnvironmentConfigFileSummaryTest {
+
+  @TempDir Path folder;
+
+  @Test
+  void tooltipShowsStoredValuesAndVariableDescriptions() throws Exception {
+    Path path = folder.resolve("env.json");
+    write(
+        path,
+        """
+        {
+          "description" : "Warehouse connection",
+          "variables" : [ {
+            "name" : "DB_HOSTNAME",
+            "value" : "${OTHER}",
+            "description" : "Warehouse host"
+          }, {
+            "name" : "DB_PORT",
+            "value" : "5432",
+            "description" : ""
+          } ]
+        }
+        """);
+
+    EnvironmentConfigFileSummary summary = 
EnvironmentConfigFileSummary.read(path.toString());
+    assertEquals("Warehouse connection", summary.getDescription());
+    String tooltip = summary.tooltip(summary.getDescription());
+
+    assertTrue(tooltip.startsWith("Warehouse connection\n\n"));
+    assertTrue(tooltip.contains("DB_HOSTNAME = ${OTHER} (Warehouse host)"));
+    assertTrue(tooltip.contains("DB_PORT = 5432"));
+    assertFalse(tooltip.contains("resolved-host"));
+  }
+
+  @Test
+  void tooltipMasksSecretsAndEncryptedValues() throws Exception {
+    Path path = folder.resolve("secrets.json");
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(path.toString());
+    file.setDescribedVariable(new DescribedVariable("DB_PASSWORD", "s3cret", 
""));
+    file.setDescribedVariable(new DescribedVariable("API_TOKEN", "abc", 
"api"));
+    file.setDescribedVariable(new DescribedVariable("my_secret", "hide-me", 
""));
+    file.setDescribedVariable(new DescribedVariable("credential_file", 
"hide-too", ""));
+    file.setDescribedVariable(new DescribedVariable("DB_PASSWD", 
"hide-passwd", ""));
+    file.setDescribedVariable(
+        new DescribedVariable("DB_HOSTNAME", Encr.PASSWORD_ENCRYPTED_PREFIX + 
"xyz", "host"));
+    file.setDescribedVariable(new DescribedVariable("PLAIN", "visible", ""));
+    file.saveToFile();
+
+    String tooltip = 
EnvironmentConfigFileSummary.read(path.toString()).tooltip(null);
+
+    assertTrue(tooltip.contains("DB_PASSWORD = " + 
EnvironmentConfigFileSummary.MASKED_VALUE));
+    assertTrue(
+        tooltip.contains("API_TOKEN = " + 
EnvironmentConfigFileSummary.MASKED_VALUE + " (api)"));
+    assertTrue(tooltip.contains("my_secret = " + 
EnvironmentConfigFileSummary.MASKED_VALUE));
+    assertTrue(tooltip.contains("credential_file = " + 
EnvironmentConfigFileSummary.MASKED_VALUE));
+    assertTrue(tooltip.contains("DB_PASSWD = " + 
EnvironmentConfigFileSummary.MASKED_VALUE));
+    assertTrue(
+        tooltip.contains("DB_HOSTNAME = " + 
EnvironmentConfigFileSummary.MASKED_VALUE + " (host)"));
+    assertTrue(tooltip.contains("PLAIN = visible"));
+    assertFalse(tooltip.contains("s3cret"));
+    assertFalse(tooltip.contains("hide-me"));
+    assertFalse(tooltip.contains(Encr.PASSWORD_ENCRYPTED_PREFIX + "xyz"));
+  }
+
+  @Test
+  void tooltipCapsTheVariableList() throws Exception {
+    Path path = folder.resolve("many.json");
+    DescribedVariablesConfigFile file = new 
DescribedVariablesConfigFile(path.toString());
+    int count = EnvironmentConfigFileSummary.MAX_VARIABLES_IN_TOOLTIP + 1;
+    for (int i = 1; i <= count; i++) {
+      file.setDescribedVariable(new 
DescribedVariable(String.format("NAME_%02d", i), "v" + i, ""));
+    }
+    file.saveToFile();
+
+    String tooltip = 
EnvironmentConfigFileSummary.read(path.toString()).tooltip("Many");
+    assertTrue(tooltip.contains("NAME_01 = v1"));
+    assertTrue(tooltip.contains("NAME_40 = v40"));
+    assertFalse(tooltip.contains("NAME_41 ="));
+    assertTrue(
+        tooltip.contains(
+            BaseMessages.getString(
+                EnvironmentConfigFileSummary.class,
+                "LifecycleEnvironmentDialog.ConfigFile.ToolTip.More",
+                "1")));
+  }
+
+  @Test
+  void tooltipForMissingUnreadableAndEmptyFiles() throws Exception {
+    Path missing = folder.resolve("missing.json");
+    EnvironmentConfigFileSummary missingSummary =
+        EnvironmentConfigFileSummary.read(missing.toString());
+    assertNull(missingSummary.getDescription());
+    assertEquals(
+        message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.Missing"),
+        missingSummary.tooltip(null));
+    assertEquals(
+        "Note\n\n" + 
message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.Missing"),
+        missingSummary.tooltip("  Note  "));
+
+    Path broken = folder.resolve("broken.json");
+    Files.writeString(broken, "{");
+    EnvironmentConfigFileSummary brokenSummary =
+        EnvironmentConfigFileSummary.read(broken.toString());
+    assertEquals(
+        message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.Unreadable"),
+        brokenSummary.tooltip(""));
+
+    Path empty = folder.resolve("empty.json");
+    Files.writeString(empty, "{ \"description\" : \"Only a note\", 
\"variables\" : [ ] }");
+    EnvironmentConfigFileSummary emptySummary = 
EnvironmentConfigFileSummary.read(empty.toString());
+    assertEquals(
+        "Only a note\n\n" + 
message("LifecycleEnvironmentDialog.ConfigFile.ToolTip.NoVariables"),
+        emptySummary.tooltip(emptySummary.getDescription()));
+  }
+
+  @Test
+  void unchangedDescriptionDoesNotRewriteTheFile() throws Exception {
+    Path path = folder.resolve("same.json");
+    String original =
+        """
+        {
+          "description" : "keep",
+          "variables" : [ {
+            "name" : "A",
+            "value" : "1",
+            "description" : ""
+          } ]
+        }
+        """;
+    Files.writeString(path, original);
+
+    
assertFalse(EnvironmentConfigFileSummary.saveDescriptionIfChanged(path.toString(),
 " keep "));
+    assertEquals(original, Files.readString(path));
+  }
+
+  @Test
+  void changedDescriptionKeepsVariablesAndOtherKeys() throws Exception {
+    Path path = folder.resolve("change.json");
+    Files.writeString(
+        path,
+        """
+        { "description" : "old", "extra" : "keep", "variables" : [ { "name" : 
"A", "value" : "1", "description" : "d" } ] }
+        """);
+
+    
assertTrue(EnvironmentConfigFileSummary.saveDescriptionIfChanged(path.toString(),
 " new "));
+
+    DescribedVariablesConfigFile loaded = new 
DescribedVariablesConfigFile(path.toString());
+    loaded.readFromFile();
+    assertEquals("new", loaded.getDescription());
+    assertEquals("keep", loaded.getConfigMap().get("extra"));
+    assertEquals("1", loaded.findDescribedVariableValue("A"));
+    assertEquals("d", loaded.findDescribedVariable("A").getDescription());
+  }
+
+  @Test
+  void clearingTheDescriptionRemovesTheKey() throws Exception {
+    Path path = folder.resolve("clear.json");
+    DescribedVariablesConfigFile created = new 
DescribedVariablesConfigFile(path.toString());
+    created.setDescription("gone");
+    created.setDescribedVariable(new DescribedVariable("A", "1", ""));
+    created.saveToFile();
+
+    
assertTrue(EnvironmentConfigFileSummary.saveDescriptionIfChanged(path.toString(),
 "   "));
+
+    DescribedVariablesConfigFile loaded = new 
DescribedVariablesConfigFile(path.toString());
+    loaded.readFromFile();
+    assertNull(loaded.getDescription());
+    
assertNull(loaded.getConfigMap().get(DescribedVariablesConfigFile.HOP_DESCRIPTION_KEY));
+    assertEquals("1", loaded.findDescribedVariableValue("A"));
+  }
+
+  @Test
+  void createsAMissingFileOnlyWhenTheDescriptionIsSet() throws Exception {
+    Path created = folder.resolve("created.json");
+    
assertFalse(EnvironmentConfigFileSummary.saveDescriptionIfChanged(created.toString(),
 "  "));
+    assertFalse(Files.exists(created));
+
+    assertTrue(
+        
EnvironmentConfigFileSummary.saveDescriptionIfChanged(created.toString(), 
"Created"));
+    DescribedVariablesConfigFile loaded = new 
DescribedVariablesConfigFile(created.toString());
+    loaded.readFromFile();
+    assertEquals("Created", loaded.getDescription());
+    assertTrue(loaded.getDescribedVariables().isEmpty());
+  }
+
+  @Test
+  void refusesToOverwriteAnUnreadableFile() throws Exception {
+    Path broken = folder.resolve("broken.json");
+    Files.writeString(broken, "{");
+
+    
assertFalse(EnvironmentConfigFileSummary.saveDescriptionIfChanged(broken.toString(),
 ""));
+    assertEquals("{", Files.readString(broken));
+
+    HopException error =
+        assertThrows(
+            HopException.class,
+            () -> 
EnvironmentConfigFileSummary.saveDescriptionIfChanged(broken.toString(), 
"new"));
+    assertTrue(error.getMessage().contains(broken.toString()));
+    assertEquals("{", Files.readString(broken));
+  }
+
+  private static void write(Path path, String json) throws Exception {
+    Files.writeString(path, json);
+  }
+
+  private static String message(String key) {
+    return BaseMessages.getString(EnvironmentConfigFileSummary.class, key);
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
index 17d57a9861..3c798dd61f 100644
--- 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
@@ -34,6 +34,7 @@ import java.util.stream.Stream;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 import org.apache.hop.core.Const;
+import org.apache.hop.core.config.DescribedVariablesConfigFile;
 import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.core.logging.LogChannel;
@@ -327,6 +328,12 @@ public class ProjectsConfigHelperTest {
         variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME));
     assertEquals("test_val", variables.getVariable("TEST_ENV_VAR"));
     assertEquals("my-env", 
variables.getVariable(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME));
+
+    DescribedVariablesConfigFile environmentFile =
+        new DescribedVariablesConfigFile(confFile.toString());
+    environmentFile.readFromFile();
+    assertEquals("my environment config", environmentFile.getDescription());
+    assertEquals("test_val", 
environmentFile.findDescribedVariableValue("TEST_ENV_VAR"));
   }
 
   @Test
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java
 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java
index b3eafc3fb2..e7904ce413 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java
@@ -37,10 +37,12 @@ import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Dialog;
 import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
 
 /** Allows the user to edit the system settings of the hop.config file. */
 public class HopDescribedVariablesDialog extends Dialog {
@@ -54,6 +56,14 @@ public class HopDescribedVariablesDialog extends Dialog {
   private List<DescribedVariable> describedVariables;
   private String selectedVariable;
 
+  /**
+   * When true, the dialog edits the configuration file's own description as 
well as its variables.
+   */
+  private boolean editFileDescription;
+
+  private String fileDescription;
+  private Text wFileDescription;
+
   /**
    * Constructs a new dialog
    *
@@ -72,6 +82,29 @@ public class HopDescribedVariablesDialog extends Dialog {
     props = PropsUi.getInstance();
   }
 
+  /**
+   * Show a single-line description stored on the configuration file itself, 
next to its variables.
+   *
+   * <p>Call this before {@link #open()}. System variables, database variables 
and the import dialog
+   * leave it off: only an environment configuration file has this field.
+   *
+   * @param description current file description, or null when the file has 
none
+   */
+  public void setFileDescriptionEditing(String description) {
+    this.editFileDescription = true;
+    this.fileDescription = description;
+  }
+
+  /**
+   * The file description from the dialog.
+   *
+   * @return the entered text, or null when the dialog was cancelled or file 
descriptions are not
+   *     being edited
+   */
+  public String getFileDescription() {
+    return fileDescription;
+  }
+
   public List<DescribedVariable> open() {
     Shell parent = getParent();
 
@@ -117,6 +150,31 @@ public class HopDescribedVariablesDialog extends Dialog {
     fdlFields.top = new FormAttachment(0, margin);
     wlFields.setLayoutData(fdlFields);
 
+    Control descriptionAnchor = wlFields;
+    if (editFileDescription) {
+      Label wlFileDescription = new Label(shell, SWT.NONE);
+      wlFileDescription.setText(
+          BaseMessages.getString(PKG, 
"HopDescribedVariablesDialog.FileDescription.Label"));
+      PropsUi.setLook(wlFileDescription);
+      FormData fdlFileDescription = new FormData();
+      fdlFileDescription.left = new FormAttachment(0, 0);
+      fdlFileDescription.top = new FormAttachment(wlFields, margin);
+      fdlFileDescription.right = new FormAttachment(100, 0);
+      wlFileDescription.setLayoutData(fdlFileDescription);
+
+      wFileDescription = new Text(shell, SWT.BORDER | SWT.SINGLE);
+      PropsUi.setLook(wFileDescription);
+      wFileDescription.setText(Const.NVL(fileDescription, ""));
+      wFileDescription.setToolTipText(
+          BaseMessages.getString(PKG, 
"HopDescribedVariablesDialog.FileDescription.ToolTip"));
+      FormData fdFileDescription = new FormData();
+      fdFileDescription.left = new FormAttachment(0, 0);
+      fdFileDescription.top = new FormAttachment(wlFileDescription, margin);
+      fdFileDescription.right = new FormAttachment(100, 0);
+      wFileDescription.setLayoutData(fdFileDescription);
+      descriptionAnchor = wFileDescription;
+    }
+
     int fieldsRows = 0;
 
     ColumnInfo[] columns = {
@@ -155,7 +213,7 @@ public class HopDescribedVariablesDialog extends Dialog {
 
     FormData fdFields = new FormData();
     fdFields.left = new FormAttachment(0, 0);
-    fdFields.top = new FormAttachment(wlFields, 2 * margin);
+    fdFields.top = new FormAttachment(descriptionAnchor, 2 * margin);
     fdFields.right = new FormAttachment(100, 0);
     fdFields.bottom = new FormAttachment(wOk, -2 * margin);
     wFields.setLayoutData(fdFields);
@@ -209,10 +267,14 @@ public class HopDescribedVariablesDialog extends Dialog {
 
   private void cancel() {
     describedVariables = null;
+    fileDescription = null;
     dispose();
   }
 
   private void ok() {
+    if (editFileDescription && wFileDescription != null && 
!wFileDescription.isDisposed()) {
+      fileDescription = wFileDescription.getText();
+    }
     describedVariables.clear();
     for (int i = 0; i < wFields.nrNonEmpty(); i++) {
       TableItem item = wFields.getNonEmpty(i);
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index d65c6b167e..79b2a51123 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -3103,12 +3103,17 @@ public class HopGui
       String selectedVariable)
       throws HopException {
     String message = "Editing configuration file: " + configFilename;
+    // Read this before getDescribedVariables(): that call can replace the 
config map with a nested
+    // "config" object, which would hide a description stored next to the 
variables.
+    String fileDescription = variablesConfigFile.getDescription();
     HopDescribedVariablesDialog variablesDialog =
         new HopDescribedVariablesDialog(
             shell, message, variablesConfigFile.getDescribedVariables(), 
selectedVariable);
+    variablesDialog.setFileDescriptionEditing(fileDescription);
     List<DescribedVariable> vars = variablesDialog.open();
     if (vars != null) {
       variablesConfigFile.setDescribedVariables(vars);
+      variablesConfigFile.setDescription(variablesDialog.getFileDescription());
       variablesConfigFile.saveToFile();
       return true;
     }
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index cf8ecde5a4..9500563d78 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -319,6 +319,8 @@ GetQueryFieldsProgressDialog.Error.Title=Error getting 
information
 GetTableSizeProgressDialog.Error.Message=An error occured getting information 
from the database\!
 GetTableSizeProgressDialog.Error.Title=Error getting information
 HopDescribedVariablesDialog.Button.EncodeValue=Encode value
+HopDescribedVariablesDialog.FileDescription.Label=Description
+HopDescribedVariablesDialog.FileDescription.ToolTip=Short note stored in this 
configuration file, next to its variables
 HopDescribedVariablesDialog.Title=Hop described variables dialog
 HopPropertiesFileDialog.Description.Label=Description
 HopPropertiesFileDialog.Exception.ErrorLoadingData.Message=Error loading data

Reply via email to