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 55966ec753 Fix metadata tree full reload on editor dirty, fixes #7791 
(#7792)
55966ec753 is described below

commit 55966ec753ac66c799eb8098443c8a8f7660d574
Author: Matt Casters <[email protected]>
AuthorDate: Wed Aug 5 20:25:06 2026 +0200

    Fix metadata tree full reload on editor dirty, fixes #7791 (#7792)
    
    Marking a metadata editor dirty no longer reloads the entire metadata
    tree or fires MetadataChanged. Tab decoration updates only; the event
    fires after a successful save. Tree refresh peeks virtualPath from JSON
    instead of fully deserializing every object.
---
 .../hop/metadata/api/IHopMetadataSerializer.java   | 18 +++++++
 .../serializer/json/JsonMetadataSerializer.java    | 60 ++++++++++++++++++++++
 .../memory/MemoryMetadataSerializer.java           | 13 +++++
 .../serializer/multi/MultiMetadataSerializer.java  | 15 ++++++
 .../json/JsonMetadataSerializerTest.java           | 45 ++++++++++++++++
 .../hop/ui/core/metadata/MetadataEditor.java       | 12 ++---
 .../perspective/metadata/MetadataPerspective.java  | 25 +++++----
 7 files changed, 169 insertions(+), 19 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java 
b/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
index f8e3560b51..94a7d2068f 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
@@ -67,6 +67,24 @@ public interface IHopMetadataSerializer<T extends 
IHopMetadata> {
    */
   List<String> listObjectNames() throws HopException;
 
+  /**
+   * Read only the virtual path of the named object. Implementations should 
prefer a cheap path
+   * (e.g. streaming a single JSON field) over a full {@link #load(String)} 
when possible. Used by
+   * the metadata perspective tree to avoid deserializing every object on 
refresh.
+   *
+   * @param name The name of the object
+   * @return The virtual path, never {@code null} (empty string when unset)
+   * @throws HopException if the object does not exist or cannot be read
+   */
+  default String readVirtualPath(String name) throws HopException {
+    T object = load(name);
+    if (object == null) {
+      throw new HopException("Object '" + name + "' does not exist");
+    }
+    String virtualPath = object.getVirtualPath();
+    return virtualPath == null ? "" : virtualPath;
+  }
+
   /**
    * See if an object with the given name exists.
    *
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
index a4b95333fd..fde873cf1f 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
@@ -303,4 +303,64 @@ public class JsonMetadataSerializer<T extends 
IHopMetadata> implements IHopMetad
     }
     return HopVfs.fileExists(calculateFilename(name));
   }
+
+  /**
+   * Stream-parse the JSON file and extract only the top-level {@code 
virtualPath} field without
+   * constructing the managed metadata type. Used by the metadata perspective 
tree refresh so large
+   * projects do not fully deserialize every object (issue #7791).
+   */
+  @Override
+  public String readVirtualPath(String name) throws HopException {
+    if (name == null) {
+      throw new HopException("Error: you need to specify the name of the 
metadata object to load");
+    }
+    validateBaseFolder(false);
+    if (!baseFolderExists || !exists(name)) {
+      throw new HopException("Object '" + name + "' does not exist");
+    }
+
+    String filename = calculateFilename(name);
+    try (InputStream fileInputStream = HopVfs.getInputStream(filename)) {
+      JsonFactory jsonFactory = new JsonFactory();
+      try (com.fasterxml.jackson.core.JsonParser jsonParser =
+          jsonFactory.createParser(fileInputStream)) {
+        if (jsonParser.nextToken() != 
com.fasterxml.jackson.core.JsonToken.START_OBJECT) {
+          throw new HopException("Expected a JSON object in file '" + filename 
+ "'");
+        }
+        while (jsonParser.nextToken() != 
com.fasterxml.jackson.core.JsonToken.END_OBJECT
+            && jsonParser.currentToken() != null) {
+          if (jsonParser.currentToken() != 
com.fasterxml.jackson.core.JsonToken.FIELD_NAME) {
+            continue;
+          }
+          String fieldName = jsonParser.currentName();
+          jsonParser.nextToken(); // move to value
+          if ("virtualPath".equals(fieldName)) {
+            if (jsonParser.currentToken() == 
com.fasterxml.jackson.core.JsonToken.VALUE_NULL) {
+              return "";
+            }
+            if (jsonParser.currentToken() == 
com.fasterxml.jackson.core.JsonToken.VALUE_STRING) {
+              return Const.NVL(jsonParser.getText(), "");
+            }
+            // Unexpected type for virtualPath — treat as empty rather than 
failing the tree.
+            jsonParser.skipChildren();
+            return "";
+          }
+          // Skip nested objects/arrays for other fields; scalars are no-ops 
for skipChildren.
+          jsonParser.skipChildren();
+        }
+        // Field absent: same as HopMetadataBase default.
+        return "";
+      }
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException(
+          "Error reading virtualPath of metadata object '"
+              + name
+              + "' from file '"
+              + filename
+              + "'",
+          e);
+    }
+  }
 }
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
index 1cb2180af4..e1022a3b9e 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
@@ -109,6 +109,19 @@ public class MemoryMetadataSerializer<T extends 
IHopMetadata> implements IHopMet
     return names;
   }
 
+  @Override
+  public String readVirtualPath(String name) throws HopException {
+    if (name == null) {
+      throw new HopException("Error: you need to specify the name of the 
metadata object to load");
+    }
+    T object = objectMap.get(name);
+    if (object == null) {
+      throw new HopException("Object '" + name + "' does not exist");
+    }
+    String virtualPath = object.getVirtualPath();
+    return virtualPath == null ? "" : virtualPath;
+  }
+
   @Override
   public boolean exists(String name) throws HopException {
     return objectMap.containsKey(name);
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
index 4df9e39339..c2dde189a3 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
@@ -141,6 +141,21 @@ public class MultiMetadataSerializer<T extends 
IHopMetadata> implements IHopMeta
     return new ArrayList<>(set);
   }
 
+  /** Same provider order as {@link #load(String)}: child providers override 
parent projects. */
+  @Override
+  public String readVirtualPath(String name) throws HopException {
+    List<IHopMetadataProvider> providers = multiProvider.getProviders();
+    ListIterator<IHopMetadataProvider> providerIterator = 
providers.listIterator(providers.size());
+    while (providerIterator.hasPrevious()) {
+      IHopMetadataProvider provider = providerIterator.previous();
+      IHopMetadataSerializer<T> serializer = 
provider.getSerializer(managedClass);
+      if (serializer.exists(name)) {
+        return serializer.readVirtualPath(name);
+      }
+    }
+    throw new HopException("Object '" + name + "' does not exist");
+  }
+
   @Override
   public boolean exists(String name) throws HopException {
     for (IHopMetadataProvider provider : multiProvider.getProviders()) {
diff --git 
a/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
index a2606d931a..900e25aba6 100644
--- 
a/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
+++ 
b/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
@@ -138,4 +138,49 @@ class JsonMetadataSerializerTest {
     //
     assertThrows(HopException.class, () -> personSerializer.load("Gardener"));
   }
+
+  /**
+   * Tree refresh only needs name + virtualPath. readVirtualPath must not 
fully deserialize and must
+   * return the path (or empty) even for complex objects (issue #7791).
+   */
+  @Test
+  void testReadVirtualPath(@TempDir Path folder) throws Exception {
+    JsonMetadataProvider provider =
+        new JsonMetadataProvider(
+            new HopTwoWayPasswordEncoder(),
+            folder.toString(),
+            Variables.getADefaultVariableSpace());
+
+    Occupation occupation = new Occupation("Occupation1", "Description of 
occupation1", 2001);
+    occupation.setVirtualPath("jobs/tech");
+    provider.getSerializer(Occupation.class).save(occupation);
+
+    IHopMetadataSerializer<Occupation> serializer = 
provider.getSerializer(Occupation.class);
+    assertEquals("jobs/tech", serializer.readVirtualPath("Occupation1"));
+
+    // Field absent → empty string (HopMetadataBase default).
+    Occupation noPath = new Occupation("NoPath", "desc", 2002);
+    serializer.save(noPath);
+    assertEquals("", serializer.readVirtualPath("NoPath"));
+
+    assertThrows(HopException.class, () -> 
serializer.readVirtualPath("Missing"));
+  }
+
+  @Test
+  void testReadVirtualPathCorruptFile(@TempDir Path folder) throws Exception {
+    JsonMetadataProvider provider =
+        new JsonMetadataProvider(
+            new HopTwoWayPasswordEncoder(),
+            folder.toString(),
+            Variables.getADefaultVariableSpace());
+
+    IHopMetadataSerializer<Occupation> serializer = 
provider.getSerializer(Occupation.class);
+    Occupation occupation = new Occupation("Broken", "desc", 2001);
+    serializer.save(occupation);
+
+    Path jsonFile = folder.resolve("occupation").resolve("Broken.json");
+    Files.write(jsonFile, "not-valid-json{".getBytes(UTF_8));
+
+    assertThrows(HopException.class, () -> 
serializer.readVirtualPath("Broken"));
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java 
b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
index 9d8017ca52..5a06c165da 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
@@ -24,7 +24,6 @@ import lombok.Getter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
-import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.extension.ExtensionPointHandler;
 import org.apache.hop.core.extension.HopExtensionPoint;
 import org.apache.hop.core.plugins.IPlugin;
@@ -198,12 +197,9 @@ public abstract class MetadataEditor<T extends 
IHopMetadata> extends MetadataFil
   public void setChanged() {
     if (!this.isChanged) {
       this.isChanged = true;
+      // Update tab decoration and toolbar only. Do not fire MetadataChanged 
and do not reload
+      // the metadata tree: dirty is not a persisted store change (see issue 
#7791).
       MetadataPerspective.getInstance().updateEditor(this);
-      try {
-        hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
-      } catch (HopException e) {
-        throw new HopRuntimeException(e);
-      }
     }
   }
 
@@ -335,6 +331,10 @@ public abstract class MetadataEditor<T extends 
IHopMetadata> extends MetadataFil
     }
 
     MetadataPerspective.getInstance().updateEditor(this);
+
+    // Notify the GUI that the metadata store changed (tree refresh, plugins). 
Fire only after a
+    // successful persist — not when the editor is merely marked dirty (issue 
#7791).
+    hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
   }
 
   @Override
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index a07e57e31d..d52ba3c69a 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -742,8 +742,8 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
                   hopGui.getVariables(),
                   HopExtensionPoint.HopGuiMetadataObjectUpdated.id,
                   metadata);
+              // Listener on MetadataChanged refreshes the tree once.
               
hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
-              refresh();
             } catch (Exception e) {
               new ErrorDialog(
                   getShell(),
@@ -760,7 +760,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     try {
       MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
       manager.editWithEditor(name);
-      hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
+      // Opening an editor is not a store change — do not fire MetadataChanged 
(issue #7791).
     } catch (Exception e) {
       new ErrorDialog(
           getShell(),
@@ -1331,8 +1331,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         try {
           MetadataManager<IHopMetadata> manager = 
getMetadataManager(objectKey);
           manager.editWithEditor(objectName);
-
-          hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
+          // Opening an editor is not a store change — do not fire 
MetadataChanged (issue #7791).
         } catch (Exception e) {
           new ErrorDialog(getShell(), ERROR, "Error editing metadata", e);
         }
@@ -2286,7 +2285,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
     if (editor == null || !isInitialized()) return;
 
-    // Update TabItem
+    // Update TabItem title and dirty font only. Do not reload the metadata 
tree here:
+    // that is expensive on large projects and dirty ≠ a persisted store 
change (issue #7791).
+    // Tree content is refreshed via MetadataChanged after 
save/delete/rename/etc.
     //
     for (CTabItem item : tabFolder.getItems()) {
       if (editor.equals(item.getData())) {
@@ -2297,10 +2298,6 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
       }
     }
 
-    // Update TreeItem
-    //
-    this.refresh();
-
     // Update HOP GUI menu and toolbar...
     //
     this.updateGui();
@@ -2384,9 +2381,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   /**
    * Loads the metadata model from disk into memory: one {@link 
MetadataTypeModel} per metadata type
-   * with its items (name + virtual path). This is the only place that reads 
metadata objects from
-   * the provider, so search filtering (handled entirely by {@link 
#renderTree()}) never touches
-   * disk.
+   * with its items (name + virtual path via {@link 
IHopMetadataSerializer#readVirtualPath(String)},
+   * not a full object load). Search filtering (handled entirely by {@link 
#renderTree()}) never
+   * touches disk.
    */
   private void reloadModel() {
     typeModels.clear();
@@ -2419,7 +2416,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
         for (String name : names) {
           String virtualPath;
           try {
-            virtualPath = Const.NVL(serializer.load(name).getVirtualPath(), 
"");
+            // Cheap path: name + virtualPath only (JSON peeks a single 
field). Avoid full
+            // serializer.load() for every object on each tree refresh (issue 
#7791).
+            virtualPath = Const.NVL(serializer.readVirtualPath(name), "");
           } catch (Exception e) {
             // We can't load this one: a missing plugin, corrupt JSON, ... .  
Rather than hiding it,
             // we list it under the "Unknown" category where it can be 
inspected and deleted.

Reply via email to